Skip to content

Compute API

Optimiser

optimizer

Core Optimisation Logic - Dispatcher for different optimisation strategies

run_optimization

run_optimization(normalized_input: NormalizedFlowInput, strategy: str = 'fatigue', task_locks: Optional[List[NormTaskLock]] = None, config: Optional[OptimizationConfig] = None) -> OptimizationResult

Main optimisation function - dispatches to specific optimisation strategy

Parameters:

Name Type Description Default
normalized_input NormalizedFlowInput

NormalisedFlowInput with persons, tasks, transfers, floating_tasks

required
strategy str

Optimisation strategy ("fatigue" for fatigue minimisation)

'fatigue'
task_locks Optional[List[NormTaskLock]]

Optional task locking constraints

None
config Optional[OptimizationConfig]

Optimisation configuration

None

Returns:

Type Description
OptimizationResult

OptimizationResult with assignments and metrics

Source code in compute/src/optimizer.py
def run_optimization(
    normalized_input: NormalizedFlowInput,
    strategy: str = "fatigue",
    task_locks: Optional[List[NormTaskLock]] = None,
    config: Optional[OptimizationConfig] = None
) -> OptimizationResult:
    """
    Main optimisation function - dispatches to specific optimisation strategy

    Args:
        normalized_input: NormalisedFlowInput with persons, tasks, transfers, floating_tasks
        strategy: Optimisation strategy ("fatigue" for fatigue minimisation)
        task_locks: Optional task locking constraints
        config: Optimisation configuration

    Returns:
        OptimizationResult with assignments and metrics
    """
    if strategy == "fatigue":
        return optimize_with_fatigue(normalized_input, task_locks, config)
    else:
        raise ValueError(f"Unknown optimisation strategy: {strategy}")

fetch_event_data

fetch_event_data(backend_url: str, event_id: int) -> Dict[str, Any]

Fetch all necessary data for optimisation from backend

Parameters:

Name Type Description Default
backend_url str

Backend API URL

required
event_id int

Event ID to optimise

required

Returns:

Type Description
Dict[str, Any]

Event data dictionary

Source code in compute/src/optimizer.py
def fetch_event_data(backend_url: str, event_id: int) -> Dict[str, Any]:
    """
    Fetch all necessary data for optimisation from backend

    Args:
        backend_url: Backend API URL
        event_id: Event ID to optimise

    Returns:
        Event data dictionary
    """
    # TODO: Implement data fetching from backend API
    pass

validate_optimization_result

validate_optimization_result(result: OptimizationResult) -> bool

Validate that optimisation results are feasible

  • Check for conflicts
  • Verify capability requirements
  • Check time constraints

Parameters:

Name Type Description Default
result OptimizationResult

OptimizationResult from optimisation

required

Returns:

Type Description
bool

True if valid, False otherwise

Source code in compute/src/optimizer.py
def validate_optimization_result(result: OptimizationResult) -> bool:
    """
    Validate that optimisation results are feasible

    - Check for conflicts
    - Verify capability requirements
    - Check time constraints

    Args:
        result: OptimizationResult from optimisation

    Returns:
        True if valid, False otherwise
    """
    # TODO: Implement validation logic
    return True

Fatigue Optimiser

fatigue_optimizer

Fatigue-Based Optimisation using CP-SAT

This module optimises task assignments to minimise fatigue range across all persons. Features: - Task fatigue: Each task contributes fatigue based on duration and fatigue_per_minute rate - Break recovery: Idle periods >= threshold provide fatigue recovery - Task locking: Optional constraints to enforce continuity between tasks - Hard coverage: All normal tasks must be fully covered

OptimizationConfig dataclass

Configuration for fatigue optimisation.

Source code in compute/src/fatigue_optimizer.py
@dataclass
class OptimizationConfig:
    """Configuration for fatigue optimisation."""
    scale: int = 100  # Scale fatigue floats to integers for CP-SAT
    break_threshold_min: int = 30  # Minimum minutes for a segment to count as a break
    break_effect: float = -0.5  # Fatigue recovery per minute of break (negative = reduces fatigue)
    max_time_seconds: float = 30.0  # Max solver time

ProgressCallback

Bases: CpSolverSolutionCallback

Collects intermediate solution snapshots during CP-SAT search.

Source code in compute/src/fatigue_optimizer.py
class ProgressCallback(cp_model.CpSolverSolutionCallback):
    """Collects intermediate solution snapshots during CP-SAT search."""

    def __init__(self, scale: int):
        super().__init__()
        self._scale = scale
        self.snapshots: List[Dict[str, Any]] = []
        self._start = time.monotonic()

    def on_solution_callback(self):
        """Record solver objective and search metadata for progress polling."""
        self.snapshots.append({
            "solution_count": len(self.snapshots) + 1,
            "objective_value": self.ObjectiveValue() / self._scale,
            "best_bound": self.BestObjectiveBound() / self._scale,
            "wall_time": round(time.monotonic() - self._start, 2),
            "num_conflicts": self.NumConflicts(),
            "num_branches": self.NumBranches(),
        })

on_solution_callback

on_solution_callback()

Record solver objective and search metadata for progress polling.

Source code in compute/src/fatigue_optimizer.py
def on_solution_callback(self):
    """Record solver objective and search metadata for progress polling."""
    self.snapshots.append({
        "solution_count": len(self.snapshots) + 1,
        "objective_value": self.ObjectiveValue() / self._scale,
        "best_bound": self.BestObjectiveBound() / self._scale,
        "wall_time": round(time.monotonic() - self._start, 2),
        "num_conflicts": self.NumConflicts(),
        "num_branches": self.NumBranches(),
    })

OptimizationResult dataclass

Result of optimisation.

Source code in compute/src/fatigue_optimizer.py
@dataclass
class OptimizationResult:
    """Result of optimisation."""
    status: str  # "OPTIMAL", "FEASIBLE", "INFEASIBLE", "UNKNOWN"
    assignments: Dict[int, List[int]]  # task_id -> list of person_ids assigned
    capability_assignments: Dict[Tuple[int, str], List[int]]  # (task_id, capability) -> list of person_ids
    fatigue_per_person: Dict[int, float]  # person_id -> fatigue value
    breaks_per_person: Dict[int, int]  # person_id -> number of breaks
    fatigue_min: float
    fatigue_max: float
    fatigue_range: float
    solve_time: float
    errors: List[str]
    transfer_assignments: Dict[int, List[int]] = None  # transfer_id -> list of person_ids boarding
    task_details: Dict[Union[int, str], Dict[str, Any]] = None  # task_id -> {start_time, end_time, location_id, original_id}
    field_assignments: Dict[int, Dict[str, List[int]]] = None  # task/transfer_id -> {field_id -> [person_ids]}
    progress_snapshots: List[Dict[str, Any]] = None  # Intermediate solution snapshots
    diagnostics: Dict[str, Any] = None  # Versioned, structured feasibility explanation

optimize_with_fatigue

optimize_with_fatigue(normalized_input: NormalizedFlowInput, config: OptimizationConfig = None, callback: ProgressCallback = None) -> OptimizationResult

Optimise task assignments to minimise fatigue range across all persons.

Parameters:

Name Type Description Default
normalized_input NormalizedFlowInput

NormalisedFlowInput with persons, tasks, transfers, floating_tasks

required
config OptimizationConfig

Optimisation configuration

None
callback ProgressCallback

Optional external ProgressCallback for live progress tracking

None

Returns:

Type Description
OptimizationResult

OptimizationResult with assignments and fatigue analysis

Source code in compute/src/fatigue_optimizer.py
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
def optimize_with_fatigue(
    normalized_input: NormalizedFlowInput,
    config: OptimizationConfig = None,
    callback: ProgressCallback = None,
) -> OptimizationResult:
    """
    Optimise task assignments to minimise fatigue range across all persons.

    Args:
        normalized_input: NormalisedFlowInput with persons, tasks, transfers, floating_tasks
        config: Optimisation configuration
        callback: Optional external ProgressCallback for live progress tracking

    Returns:
        OptimizationResult with assignments and fatigue analysis
    """
    if config is None:
        config = OptimizationConfig()

    SCALE = config.scale
    BREAK_THRESHOLD_MIN = config.break_threshold_min
    BREAK_EFFECT_PER_MIN = config.break_effect  # recovery per minute of break

    errors = []

    print("=" * 80)
    print("FATIGUE OPTIMISER - CP-SAT")
    print("=" * 80)
    print(f"Config: scale={SCALE}, break_threshold={BREAK_THRESHOLD_MIN}min, break_effect_per_min={BREAK_EFFECT_PER_MIN}")

    # Extract data
    persons = normalized_input.persons
    tasks = list(normalized_input.tasks)  # Make a copy since we'll extend it
    transfers = normalized_input.transfers
    floating_tasks = getattr(normalized_input, "floating_tasks", [])

    preflight = preflight_issues(normalized_input)
    if preflight:
        return _empty_result(
            "INFEASIBLE",
            0.0,
            [issue.message for issue in preflight],
            persons,
            diagnostics_payload("invalid_input", preflight),
        )

    if not tasks and not transfers and not floating_tasks:
        return _empty_result(
            "OPTIMAL", 0.0, [], persons, diagnostics_payload("feasible", [])
        )

    # Generate time segments
    segments = generate_time_segments(tasks, transfers, floating_tasks)

    print(f"\n--- TIME SEGMENTS ({len(segments)}) ---")
    for i, seg in enumerate(segments):
        duration = seg.end_time - seg.start_time
        print(f"Segment {i}: {minutes_to_time_str(seg.start_time)}-{minutes_to_time_str(seg.end_time)} ({duration}min)")

    # === EXPAND FLOATING TASKS ===
    floating_candidates: Dict[int, List[int]] = {}
    original_num_tasks = len(tasks)

    if floating_tasks:
        print(f"\n--- EXPANDING {len(floating_tasks)} FLOATING TASKS ---")

    for ft_idx, ft in enumerate(floating_tasks):
        floating_candidates[ft_idx] = []

        for s_idx, seg in enumerate(segments):
            seg_start = seg.start_time
            seg_end = seg.end_time
            seg_len = seg_end - seg_start

            # Check if task can fit starting at this segment
            # Task must start at or after window_start
            if seg_start < ft.window_start_time:
                continue

            # Task must end at or before window_end
            if seg_start + ft.duration > ft.window_end_time:
                continue

            # Create candidate task with UNIQUE ID to avoid collisions in assignments dict
            # We'll store the original floating task ID separately for tracking
            candidate_task_id = f"{ft.id}_cand_{s_idx}"  # e.g., "1770042197058_cand_3"

            candidate_task = NormTask(
                id=candidate_task_id,
                name=f"{ft.name} [floating@seg{s_idx}]",
                location_id=ft.location_id,
                start_time=seg_start,
                end_time=seg_start + ft.duration,
                requirements=dict(ft.requirements),
                preassigned_person_ids=ft.preassigned_person_ids,
                counts_towards_work_time=(
                    getattr(ft, "counts_towards_work_time", True) is not False
                ),
            )

            # Store original floating task ID as an attribute for response mapping
            candidate_task.original_floating_task_id = ft.id

            # Copy fatigue_per_minute if floating task has it
            if hasattr(ft, 'fatigue_per_minute'):
                candidate_task.fatigue_per_minute = ft.fatigue_per_minute

            tasks.append(candidate_task)
            new_task_idx = len(tasks) - 1
            floating_candidates[ft_idx].append(new_task_idx)
            print(f"  '{ft.name}' -> candidate at seg{s_idx}: task[{new_task_idx}]")

        if not floating_candidates[ft_idx]:
            errors.append(f"Floating task '{ft.name}' has no feasible time slot")

    # Early return if any floating task has no feasible candidates
    if errors:
        issues = [legacy_message_issue(message, normalized_input) for message in errors]
        return _empty_result(
            "INFEASIBLE",
            0.0,
            errors,
            persons,
            diagnostics_payload("infeasible", issues),
        )

    # Validate: Tasks without capability requirements must have preassigned people
    for task in tasks:
        has_requirements = task.requirements and any(count > 0 for count in task.requirements.values())
        has_preassigned = task.preassigned_person_ids and len(task.preassigned_person_ids) > 0

        if not has_requirements and not has_preassigned:
            errors.append(f"Task '{task.name}' has no capability requirements and no preassigned people")

    if errors:
        issues = [legacy_message_issue(message, normalized_input) for message in errors]
        return _empty_result(
            "INFEASIBLE",
            0.0,
            errors,
            persons,
            diagnostics_payload("invalid_input", issues),
        )

    # Build reverse map: task_idx -> floating task index
    task_to_floating: Dict[int, int] = {}
    for ft_idx, cand_task_indices in floating_candidates.items():
        for t_idx in cand_task_indices:
            task_to_floating[t_idx] = ft_idx

    # Rebuild segment task indices
    # A task is active in a segment if the segment STARTS during the task's execution
    # This matches flow_checker.py's logic: task.start_time <= segment_start < task.end_time
    for seg in segments:
        seg.task_indices = []

    for t_idx, task in enumerate(tasks):
        for s_idx, seg in enumerate(segments):
            # Task is active if segment starts during task execution
            if task.start_time <= seg.start_time < task.end_time:
                seg.task_indices.append(t_idx)

    # === COLLECT CAPABILITIES AND LOCATIONS ===
    all_capabilities = set()
    all_locations = set()

    for person in persons:
        all_capabilities.update(person.capabilities)
        if person.home_location_id is not None:
            all_locations.add(person.home_location_id)

    for task in tasks:
        all_capabilities.update(task.requirements.keys())
        if task.location_id is not None:
            all_locations.add(task.location_id)
        if hasattr(task, 'from_location_id') and task.from_location_id is not None:
            all_locations.add(task.from_location_id)
        if hasattr(task, 'to_location_id') and task.to_location_id is not None:
            all_locations.add(task.to_location_id)

    for transfer in transfers:
        all_capabilities.update(transfer.requirements.keys())
        all_locations.add(transfer.from_location_id)
        all_locations.add(transfer.to_location_id)

    capabilities = sorted(all_capabilities)
    locations = sorted(all_locations)

    # Create index mappings
    person_to_idx = {p.id: i for i, p in enumerate(persons)}
    capability_to_idx = {c: i for i, c in enumerate(capabilities)}
    location_to_idx = {loc: i for i, loc in enumerate(locations)}
    idx_to_location = {i: loc for i, loc in enumerate(locations)}

    num_persons = len(persons)
    num_tasks = len(tasks)
    num_capabilities = len(capabilities)
    num_locations = len(locations)
    num_segments = len(segments)
    num_transfers = len(transfers)

    print(f"\n--- PROBLEM SIZE ---")
    print(f"Persons: {num_persons}, Tasks: {num_tasks}, Transfers: {num_transfers}")
    print(f"Capabilities: {num_capabilities}, Locations: {num_locations}, Segments: {num_segments}")

    print(f"\n--- DEBUG: CAPABILITIES ---")
    print(f"All capabilities found: {capabilities}")
    print(f"Capability to index mapping: {capability_to_idx}")

    print(f"\n--- DEBUG: PERSON CAPABILITIES ---")
    for p_idx, person in enumerate(persons):
        print(f"Person {person.id} ({person.home_location_id if hasattr(person, 'home_location_id') else 'N/A'}): {person.capabilities}")

    # Map task to all segments it spans - using flow_checker logic
    task_segments: Dict[int, List[int]] = {}
    for t_idx, task in enumerate(tasks):
        task_segments[t_idx] = []
        for s_idx, seg in enumerate(segments):
            # Use flow_checker logic: task is active if segment starts during task execution
            if task.start_time <= seg.start_time < task.end_time:
                task_segments[t_idx].append(s_idx)

    print(f"\n--- DEBUG: TASK SEGMENT MAPPING ---")
    for t_idx, task in enumerate(tasks):
        print(f"Task {t_idx} ({task.name}): spans segments {task_segments[t_idx]} (start: {task.start_time}, end: {task.end_time})")

    # Availability matrix
    availability = [[True for _ in range(num_segments)] for _ in range(num_persons)]

    for p_idx, person in enumerate(persons):
        for s_idx, seg in enumerate(segments):
            seg_start = seg.start_time
            seg_end = seg.end_time
            for (ua_start, ua_end) in getattr(person, 'unavailable_intervals', []):
                if ua_start < seg_end and seg_start < ua_end:
                    availability[p_idx][s_idx] = False
                    break

    # Debug: print availability matrix (if there's unavailability)
    if any(hasattr(p, 'unavailable_intervals') and p.unavailable_intervals for p in persons):
        print("\n--- AVAILABILITY MATRIX ---")
        for p_idx, person in enumerate(persons):
            avail_str = ", ".join([f"Seg{s}:{'Y' if availability[p_idx][s] else 'N'}" for s in range(num_segments)])
            print(f"Person {person.id}: {avail_str}")
            if hasattr(person, 'unavailable_intervals') and person.unavailable_intervals:
                print(f"  Unavailable: {person.unavailable_intervals}")

    # Calculate task fatigue costs
    task_fatigue_cost = []
    for t_idx, task in enumerate(tasks):
        duration = task.end_time - task.start_time
        fatigue_rate = getattr(task, 'fatigue_per_minute', 0.0)
        cost = round(fatigue_rate * duration * SCALE)
        task_fatigue_cost.append(cost)

    print(f"\n--- FATIGUE COSTS ---")
    print(f"Task fatigue costs: min={min(task_fatigue_cost) if task_fatigue_cost else 0}, max={max(task_fatigue_cost) if task_fatigue_cost else 0}")

    # Pre-compute per-segment break recovery costs (scaled integers).
    # Each segment that qualifies as a break recovers proportional to its duration.
    break_cost_per_seg = []
    for seg in segments:
        seg_dur = seg.end_time - seg.start_time
        if seg_dur >= BREAK_THRESHOLD_MIN:
            break_cost_per_seg.append(round(BREAK_EFFECT_PER_MIN * seg_dur * SCALE))
        else:
            break_cost_per_seg.append(0)
    print(f"Break recovery/min: {BREAK_EFFECT_PER_MIN} (threshold {BREAK_THRESHOLD_MIN}min)")
    if break_cost_per_seg:
        non_zero = [c for c in break_cost_per_seg if c != 0]
        if non_zero:
            print(f"Break segment costs (non-zero): min={min(non_zero)}, max={max(non_zero)}")

    # === BUILD CP-SAT MODEL ===
    model = cp_model.CpModel()
    assumptions = AssumptionRegistry(model)

    # Decision variables
    x = {}  # x[p,t,c]: person p covers capability c for task t
    for p in range(num_persons):
        for t in range(num_tasks):
            for c in range(num_capabilities):
                x[p, t, c] = model.NewBoolVar(f'x_p{p}_t{t}_c{c}')

    assigned = {}  # assigned[p,t]: person p is assigned (organizer) for task t
    for p in range(num_persons):
        for t in range(num_tasks):
            assigned[p, t] = model.NewBoolVar(f'assigned_p{p}_t{t}')

    z = {}  # z[p,s,l]: person p is at location l in segment s
    for p in range(num_persons):
        for s in range(num_segments):
            for l in range(num_locations):
                z[p, s, l] = model.NewBoolVar(f'z_p{p}_s{s}_l{l}')

    y = {}  # y[p,k]: person p uses transfer k
    for p in range(num_persons):
        for k in range(num_transfers):
            y[p, k] = model.NewBoolVar(f'y_p{p}_k{k}')

    task_fully_covered = []  # task_fully_covered[t]: task t has all requirements satisfied
    for t in range(num_tasks):
        var = model.NewBoolVar(f'task_fully_covered_{t}')
        task_fully_covered.append(var)

    float_choice = {}  # float_choice[ft_idx, t_idx]: choose which candidate for floating task
    for ft_idx, cand_task_indices in floating_candidates.items():
        for t_idx in cand_task_indices:
            float_choice[ft_idx, t_idx] = model.NewBoolVar(f'float_choice_ft{ft_idx}_t{t_idx}')

    # === CREATE TASK_ACTIVE VARIABLES ===
    # These indicate whether a person is working on a task in a specific segment
    # (either assigned or providing a capability for that task)
    task_active_vars = {}  # (p, s_idx, t_idx) -> BoolVar

    for p in range(num_persons):
        for s_idx, segment in enumerate(segments):
            for t_idx in segment.task_indices:
                activity_vars = [assigned[p, t_idx]]
                for c in range(num_capabilities):
                    activity_vars.append(x[p, t_idx, c])

                # Create a boolean: is person working on this task in this segment?
                task_active = model.NewBoolVar(f'task_active_p{p}_s{s_idx}_t{t_idx}')
                # task_active is 1 if any of the activity vars for this task is 1
                model.AddMaxEquality(task_active, activity_vars)
                task_active_vars[(p, s_idx, t_idx)] = task_active

    task_durations = [task.end_time - task.start_time for task in tasks]
    transfer_durations = []
    for transfer in transfers:
        duration = transfer.arrive_time - transfer.depart_time
        if duration < 0:
            duration += 24 * 60
        transfer_durations.append(max(0, duration))

    working = {}  # working[p,t]: person p works on task t (in any segment)
    for p in range(num_persons):
        for t in range(num_tasks):
            working[p, t] = model.NewBoolVar(f'working_p{p}_t{t}')

    # Link working to task_active across all segments
    # Person works on a task if they're active on it in ANY segment it spans
    for p in range(num_persons):
        for t in range(num_tasks):
            task_active_for_this_task = []
            for s_idx, segment in enumerate(segments):
                if t in segment.task_indices and (p, s_idx, t) in task_active_vars:
                    task_active_for_this_task.append(task_active_vars[(p, s_idx, t)])

            if task_active_for_this_task:
                # working is true if task_active in any segment
                model.AddMaxEquality(working[p, t], task_active_for_this_task)
            else:
                model.Add(working[p, t] == 0)

    # Total work time per person
    work_task_durations = [
        duration
        if getattr(task, "counts_towards_work_time", True) is not False
        else 0
        for task, duration in zip(tasks, task_durations)
    ]
    work_transfer_durations = [
        duration
        if getattr(transfer, "counts_towards_work_time", True) is not False
        else 0
        for transfer, duration in zip(transfers, transfer_durations)
    ]
    max_possible_time = (sum(work_task_durations) if work_task_durations else 0) + (
        sum(work_transfer_durations) if work_transfer_durations else 0
    )
    work_time = {}
    for p in range(num_persons):
        work_time[p] = model.NewIntVar(0, max_possible_time, f'work_time_p{p}')
        model.Add(
            work_time[p] ==
            sum(work_task_durations[t] * working[p, t] for t in range(num_tasks)) +
            sum(work_transfer_durations[k] * y[p, k] for k in range(num_transfers))
        )

    # Enforce maximum work time per person
    for p_idx, person in enumerate(persons):
        if hasattr(person, 'max_work_minutes_per_day') and person.max_work_minutes_per_day is not None:
            model.Add(work_time[p_idx] <= int(person.max_work_minutes_per_day))

    # === IDLE AND BREAK VARIABLES ===
    idle = {}  # idle[p,s]: person p is idle (available but not working) in segment s
    for p in range(num_persons):
        for s in range(num_segments):
            idle[p, s] = model.NewBoolVar(f'idle_p{p}_s{s}')

    break_seg = {}  # break_seg[p,s]: segment s counts as a break for person p
    for p in range(num_persons):
        for s in range(num_segments):
            break_seg[p, s] = model.NewBoolVar(f'break_p{p}_s{s}')

    print("\n--- ADDING CONSTRAINTS ---")

    # === INITIAL LOCATION ===
    print("\n--- DEBUG: INITIAL LOCATION CONSTRAINTS ---")
    if num_segments > 0:
        first_seg_start = segments[0].start_time
        for p_idx, person in enumerate(persons):
            # Check if person had unavailability before first segment
            had_unavailability_before = False
            for (ua_start, ua_end) in getattr(person, "unavailable_intervals", []):
                if ua_end <= first_seg_start:
                    had_unavailability_before = True
                    break

            # Check if preassigned in first segment
            preassigned_first_loc = None
            for t_idx, task in enumerate(tasks):
                if person.id in task.preassigned_person_ids and t_idx in segments[0].task_indices:
                    preassigned_first_loc = task.location_id
                    break

            if preassigned_first_loc is not None:
                task_loc_idx = location_to_idx[preassigned_first_loc]
                model.Add(z[p_idx, 0, task_loc_idx] == 1)
                print(f"  Person {person.id}: Fixed to location {preassigned_first_loc} (idx {task_loc_idx}) - preassigned")
            elif not had_unavailability_before and person.home_location_id is not None and person.home_location_id in location_to_idx:
                home_loc_idx = location_to_idx[person.home_location_id]
                model.Add(z[p_idx, 0, home_loc_idx] == 1)
                print(f"  Person {person.id}: Fixed to home location {person.home_location_id} (idx {home_loc_idx})")
            else:
                print(f"  Person {person.id}: No initial location constraint (had_unavail={had_unavailability_before}, home={person.home_location_id})")
    print("[OK] Initial locations")

    # === ONE LOCATION PER SEGMENT ===
    for p in range(num_persons):
        for s in range(num_segments):
            if availability[p][s]:
                # Person must be at exactly one location when available
                model.Add(sum(z[p, s, l] for l in range(num_locations)) == 1)
            else:
                # Person is unavailable - they can be at any location or none
                # (we could force them to 0 locations, but allowing any is more flexible)
                model.Add(sum(z[p, s, l] for l in range(num_locations)) <= 1)
    print("[OK] One location per segment")

    # === LOCATION PROPAGATION ===
    for p in range(num_persons):
        for s in range(1, num_segments):
            for l in range(num_locations):
                if availability[p][s-1] and availability[p][s]:
                    # Find incoming transfers
                    incoming_transfers = []
                    for k, transfer in enumerate(transfers):
                        to_loc_idx = location_to_idx[transfer.to_location_id]
                        if to_loc_idx == l and segments[s].start_time == transfer.arrive_time:
                            incoming_transfers.append(k)

                    # Find incoming moving tasks
                    incoming_moving_tasks = []
                    for t_idx, task in enumerate(tasks):
                        if hasattr(task, 'to_location_id') and task.to_location_id is not None:
                            to_loc_idx = location_to_idx[task.to_location_id]
                            if to_loc_idx == l and segments[s].start_time == task.end_time:
                                incoming_moving_tasks.append(t_idx)

                    model.Add(
                        z[p, s, l] <= z[p, s-1, l] + 
                        sum(y[p, k] for k in incoming_transfers) +
                        sum(working[p, t] for t in incoming_moving_tasks)
                    )
    print("[OK] Location propagation")

    # === TRANSFER CONSTRAINTS ===
    for p_idx, person in enumerate(persons):
        for k, transfer in enumerate(transfers):
            depart_segment = None
            arrive_segment = None
            for s_idx, seg in enumerate(segments):
                if seg.start_time == transfer.depart_time:
                    depart_segment = s_idx
                if seg.start_time == transfer.arrive_time:
                    arrive_segment = s_idx

            if depart_segment is not None:
                from_loc_idx = location_to_idx[transfer.from_location_id]
                to_loc_idx = location_to_idx[transfer.to_location_id]

                model.Add(y[p_idx, k] <= z[p_idx, depart_segment, from_loc_idx])

                if arrive_segment is not None:
                    model.Add(z[p_idx, arrive_segment, to_loc_idx] >= y[p_idx, k])

                if not availability[p_idx][depart_segment]:
                    model.Add(y[p_idx, k] == 0)
                if arrive_segment is not None and not availability[p_idx][arrive_segment]:
                    model.Add(y[p_idx, k] == 0)
    print("[OK] Transfer boarding")

    # === TRANSFER CAPACITY ===
    for k, transfer in enumerate(transfers):
        locked_person_indices = [
            person_to_idx[person_id]
            for person_id in getattr(transfer, "locked_person_ids", [])
            if person_id in person_to_idx
        ]
        if locked_person_indices:
            print(
                f"  Transfer {transfer.id}: Direct passengers locked to: "
                f"{getattr(transfer, 'locked_person_ids', [])}"
            )
            for p_idx in locked_person_indices:
                model.Add(y[p_idx, k] == 1)

        if hasattr(transfer, 'capacity') and transfer.capacity is not None and transfer.capacity < 999:
            model.Add(sum(y[p, k] for p in range(num_persons)) <= transfer.capacity)

        for cap_name, count in transfer.requirements.items():
            if cap_name in capability_to_idx and count > 0:
                people_with_cap = [p for p in range(num_persons) if cap_name in persons[p].capabilities]
                if people_with_cap:
                    model.Add(sum(y[p, k] for p in people_with_cap) >= count)
    print("[OK] Transfer capacity")

    # === TASK FEASIBILITY ===
    # Any-location auxiliary variables (solver chooses location)
    task_loc_choice = {}
    any_location_task_indices = [t_idx for t_idx, task in enumerate(tasks)
                                 if task.location_id is None
                                 and not (hasattr(task, 'from_location_id') and task.from_location_id is not None
                                          and hasattr(task, 'to_location_id') and task.to_location_id is not None)]

    for t_idx in any_location_task_indices:
        for l in range(num_locations):
            task_loc_choice[t_idx, l] = model.NewBoolVar(f'task_loc_choice_t{t_idx}_l{l}')
        # Task takes place at exactly one location if covered
        model.Add(
            sum(task_loc_choice[t_idx, l] for l in range(num_locations))
            == task_fully_covered[t_idx]
        )
    print(f"[OK] Any-location variables for {len(any_location_task_indices)} tasks")

    print("\n--- DEBUG: TASK FEASIBILITY CONSTRAINTS ---")
    for p_idx, person in enumerate(persons):
        for t_idx, task in enumerate(tasks):
            if not task_segments[t_idx]:
                continue

            if hasattr(task, 'from_location_id') and task.from_location_id is not None and hasattr(task, 'to_location_id') and task.to_location_id is not None:
                # Moving task: fixed from_location
                from_loc_idx = location_to_idx[task.from_location_id]
                task_loc_idx = from_loc_idx

                for c_idx, cap_name in enumerate(capabilities):
                    has_cap = 1 if cap_name in person.capabilities else 0
                    model.Add(x[p_idx, t_idx, c_idx] <= has_cap)
                    for seg_idx in task_segments[t_idx]:
                        model.Add(x[p_idx, t_idx, c_idx] <= z[p_idx, seg_idx, task_loc_idx])
                        if not availability[p_idx][seg_idx]:
                            model.Add(x[p_idx, t_idx, c_idx] == 0)
            elif task.location_id is not None:
                # Regular task with fixed location
                task_loc_idx = location_to_idx[task.location_id]

                # Debug for first task only
                if t_idx == 0 and p_idx < 3:
                    print(f"Person {p_idx} (ID {person.id}) for Task {t_idx}:")
                    print(f"  Task location: {task.location_id}, Task loc_idx: {task_loc_idx}")
                    print(f"  Task segments: {task_segments[t_idx]}")

                for c_idx, cap_name in enumerate(capabilities):
                    has_cap = 1 if cap_name in person.capabilities else 0
                    model.Add(x[p_idx, t_idx, c_idx] <= has_cap)
                    for seg_idx in task_segments[t_idx]:
                        model.Add(x[p_idx, t_idx, c_idx] <= z[p_idx, seg_idx, task_loc_idx])
                        if not availability[p_idx][seg_idx]:
                            model.Add(x[p_idx, t_idx, c_idx] == 0)
            else:
                # Any-location task: person must be at whichever location the solver picks
                for c_idx, cap_name in enumerate(capabilities):
                    has_cap = 1 if cap_name in person.capabilities else 0
                    model.Add(x[p_idx, t_idx, c_idx] <= has_cap)
                    for seg_idx in task_segments[t_idx]:
                        if not availability[p_idx][seg_idx]:
                            model.Add(x[p_idx, t_idx, c_idx] == 0)
                        else:
                            for l in range(num_locations):
                                # x[p,t,c] AND task_loc_choice[t,l] => z[p,seg,l]
                                model.Add(
                                    x[p_idx, t_idx, c_idx] + task_loc_choice[t_idx, l] - 1
                                    <= z[p_idx, seg_idx, l]
                                )
    print("[OK] Capability & location consistency (with any-location support)")

    # === TASK MOVEMENT ===
    for p in range(num_persons):
        for t_idx, task in enumerate(tasks):
            if hasattr(task, 'from_location_id') and task.from_location_id is not None and hasattr(task, 'to_location_id') and task.to_location_id is not None:
                if not task_segments[t_idx]:
                    continue

                task_start_segment = task_segments[t_idx][0]
                from_loc_idx = location_to_idx[task.from_location_id]
                to_loc_idx = location_to_idx[task.to_location_id]

                model.Add(working[p, t_idx] <= z[p, task_start_segment, from_loc_idx])

                end_segment = None
                for s_idx, seg in enumerate(segments):
                    if seg.start_time == task.end_time:
                        end_segment = s_idx
                        break

                if end_segment is not None:
                    model.Add(z[p, end_segment, to_loc_idx] >= working[p, t_idx])
    print("[OK] Task movement")

    # === CAPABILITY REQUIREMENTS ===
    required_count = {}
    for t_idx, task in enumerate(tasks):
        required_count[t_idx] = {}
        print(f"[DEBUG] Task {t_idx} ({task.name}): requirements = {task.requirements}")
        for cap_name, req in task.requirements.items():
            print(f"[DEBUG]   Capability '{cap_name}' requires {req} persons")
            if cap_name in capability_to_idx:
                c_idx = capability_to_idx[cap_name]
                required_count[t_idx][c_idx] = req
                print(f"[DEBUG]   Mapped to capability index {c_idx}")
            else:
                print(f"[DEBUG]   WARNING: Capability '{cap_name}' not found in capability_to_idx!")
                print(f"[DEBUG]   Available capabilities: {list(capability_to_idx.keys())}")

    print(f"[DEBUG] required_count mapping: {required_count}")

    print(f"\n[DEBUG] Adding capability constraints:")
    for t in range(num_tasks):
        for c in range(num_capabilities):
            req = required_count.get(t, {}).get(c, 0)
            if req > 0:
                cap_name = capabilities[c]
                # Count how many persons have this capability
                eligible_persons = sum(1 for p in range(num_persons) if cap_name in persons[p].capabilities)
                print(f"  Task {t}, Cap {c} ('{cap_name}'): requires {req} persons, {eligible_persons} persons have it")
                # Exactly req persons must provide this capability (not more, not less)
                model.Add(sum(x[p, t, c] for p in range(num_persons)) == req * task_fully_covered[t])
            else:
                # If no capability required, no one should provide it
                for p in range(num_persons):
                    model.Add(x[p, t, c] == 0)

    # Tasks with no capability requirements are automatically fully covered
    # UNLESS they are floating task candidates (which are covered only if chosen)
    for t in range(num_tasks):
        if t not in task_to_floating and all(required_count.get(t, {}).get(c, 0) == 0 for c in range(num_capabilities)):
            model.Add(task_fully_covered[t] == 1)

    # For tasks with no requirements, link task_fully_covered to someone being assigned
    # (This ensures tasks without capability requirements still need a preassigned person)
    # Skip floating task candidates as they're handled by float_choice constraints
    for t in range(num_tasks):
        if t not in task_to_floating and all(required_count.get(t, {}).get(c, 0) == 0 for c in range(num_capabilities)):
            # At least one person must be assigned if task is covered
            model.Add(sum(assigned[p, t] for p in range(num_persons)) >= task_fully_covered[t])

    # === PERSON CAN FILL AT MOST ONE CAPABILITY SLOT PER TASK ===
    # Each person can provide at most one capability for a given task.
    # This ensures that a person with multiple capabilities (e.g., is_ho AND is_nurse)
    # cannot fill two separate capability slots on the same task.
    for p in range(num_persons):
        for t in range(num_tasks):
            model.Add(sum(x[p, t, c] for c in range(num_capabilities)) <= 1)
    print("[OK] At most one capability slot per person per task")

    # NOTE: 'assigned' and capability provision (x) are INDEPENDENT
    # - assigned[p,t] = 1: person is preassigned (doesn't count toward capability requirements)
    # - x[p,t,c] = 1: person provides capability c (fills capability requirements)
    # - A preassigned person can also provide capabilities, but doesn't have to
    # - Preassigned persons don't count toward the capability requirement sum
    print("[OK] Capability requirements")

    # === FLOATING TASK CONSTRAINTS ===
    for ft_idx, cand_task_indices in floating_candidates.items():
        if cand_task_indices:
            model.Add(sum(float_choice[ft_idx, t_idx] for t_idx in cand_task_indices) == 1)

    for ft_idx, cand_task_indices in floating_candidates.items():
        for t_idx in cand_task_indices:
            choice_var = float_choice[ft_idx, t_idx]

            for p in range(num_persons):
                model.Add(assigned[p, t_idx] <= choice_var)
                for c in range(num_capabilities):
                    model.Add(x[p, t_idx, c] <= choice_var)

            model.Add(task_fully_covered[t_idx] == choice_var)
    print("[OK] Floating task constraints")

    # === ASSIGNED PERSON CONSTRAINTS ===
    # 'assigned' is ONLY for directly preassigned persons.
    # If a user selected people for a task, that direct assignment is exact:
    # selected people must be assigned, and everyone else must not be assigned
    # through the direct assignment variable.
    for t_idx, task in enumerate(tasks):
        # For floating task candidates, handle them based on preassigned persons
        if t_idx in task_to_floating:
            # If floating task has NO preassigned persons, no one can be assigned
            # (they can only participate via capability provision x[p,t,c])
            if len(task.preassigned_person_ids) == 0:
                model.Add(sum(assigned[p, t_idx] for p in range(num_persons)) == 0)
            # If floating task has preassigned persons, exact assignment is
            # handled below once candidates are grouped by floating task.
            continue

        valid_preassigned = {
            person_to_idx[person_id]
            for person_id in task.preassigned_person_ids
            if person_id in person_to_idx
        }

        if not task.preassigned_person_ids:
            # No one can be assigned if there are no preassigned persons
            model.Add(sum(assigned[p, t_idx] for p in range(num_persons)) == 0)
        else:
            print(f"  Task {task.id} ({task.name}): Direct assignments locked to: {task.preassigned_person_ids}")
            for p in range(num_persons):
                model.Add(assigned[p, t_idx] == (1 if p in valid_preassigned else 0))
    print("[OK] Exact direct assignments")

    # === PREASSIGNED TASKS ===
    floating_preassigned: Dict[int, List[int]] = {}

    for t_idx, task in enumerate(tasks):
        if task.preassigned_person_ids:
            if t_idx in task_to_floating:
                ft_idx = task_to_floating[t_idx]
                if ft_idx not in floating_preassigned:
                    floating_preassigned[ft_idx] = []
                    for person_id in task.preassigned_person_ids:
                        if person_id in person_to_idx:
                            floating_preassigned[ft_idx].append(person_to_idx[person_id])
            else:
                for person_id in task.preassigned_person_ids:
                    if person_id in person_to_idx:
                        p_fixed = person_to_idx[person_id]
                        model.Add(assigned[p_fixed, t_idx] == 1)

    # For floating tasks with preassigned persons:
    # The preassigned person must be assigned to the chosen candidate
    # AND only preassigned persons can be assigned (others must be 0)
    for ft_idx, preassigned_persons in floating_preassigned.items():
        cand_task_indices = floating_candidates[ft_idx]
        original_task = tasks[cand_task_indices[0]]
        print(
            f"  Floating task {original_task.id} ({original_task.name}): "
            f"Direct assignments locked to: {original_task.preassigned_person_ids}"
        )
        for p_fixed in preassigned_persons:
            # For each candidate: if chosen, preassigned person must be assigned to it
            for t_idx in cand_task_indices:
                model.Add(assigned[p_fixed, t_idx] == float_choice[ft_idx, t_idx])

        # For non-preassigned persons, they cannot be assigned to this floating task
        for t_idx in cand_task_indices:
            for p in range(num_persons):
                if p not in preassigned_persons:
                    model.Add(assigned[p, t_idx] == 0)
    print("[OK] Preassigned tasks")

    # === ASSIGNED PERSON LOCATION ===
    for p in range(num_persons):
        for t_idx, task in enumerate(tasks):
            if not task_segments[t_idx]:
                continue

            if hasattr(task, 'from_location_id') and task.from_location_id is not None:
                task_loc_idx = location_to_idx[task.from_location_id]
                for seg_idx in task_segments[t_idx]:
                    model.Add(assigned[p, t_idx] <= z[p, seg_idx, task_loc_idx])
                    if not availability[p][seg_idx]:
                        model.Add(assigned[p, t_idx] == 0)
            elif task.location_id is not None:
                task_loc_idx = location_to_idx[task.location_id]
                for seg_idx in task_segments[t_idx]:
                    model.Add(assigned[p, t_idx] <= z[p, seg_idx, task_loc_idx])
                    if not availability[p][seg_idx]:
                        model.Add(assigned[p, t_idx] == 0)
            else:
                # Any-location task: assigned person must be at whichever location the solver picks
                for seg_idx in task_segments[t_idx]:
                    if not availability[p][seg_idx]:
                        model.Add(assigned[p, t_idx] == 0)
                    else:
                        for l in range(num_locations):
                            model.Add(
                                assigned[p, t_idx] + task_loc_choice[t_idx, l] - 1
                                <= z[p, seg_idx, l]
                            )
    print("[OK] Assigned person location (with any-location support)")

    # === ASSIGNED SEPARATE FROM CAPABILITY ===
    # Preassigned persons CANNOT also fill capability slots.
    # This ensures 1 assigned person + 1 capability requirement = 2 distinct people.
    for t_idx, task in enumerate(tasks):
        if task.preassigned_person_ids:
            for person_id in task.preassigned_person_ids:
                if person_id in person_to_idx:
                    p_fixed = person_to_idx[person_id]
                    for c in range(num_capabilities):
                        model.Add(x[p_fixed, t_idx, c] == 0)
    print("[OK] Assigned separate from capability")

    # === NO DOUBLE-BOOKING ===
    # Use the task_active variables created earlier
    # Person can be active in at most one task/transfer per segment
    # (task_active collapses assigned + all x vars for the same task into one boolean,
    #  so a preassigned person providing a capability still counts as one activity.
    #  The per-task constraint "sum(x[p,t,c]) <= 1" above already prevents one person
    #  from filling multiple capability slots on the same task.)

    for p in range(num_persons):
        for s_idx, segment in enumerate(segments):
            activity_vars = []

            # Add task_active vars for all tasks in this segment
            for t_idx in segment.task_indices:
                if (p, s_idx, t_idx) in task_active_vars:
                    activity_vars.append(task_active_vars[(p, s_idx, t_idx)])

            # Add transfer vars
            for k_idx in segment.transfer_indices:
                activity_vars.append(y[p, k_idx])

            # Person can be active in at most one task/transfer per segment
            if activity_vars:
                model.Add(sum(activity_vars) <= 1)
    print("[OK] No double-booking")

    # === IDLE SEGMENT TRACKING ===
    # First, determine which persons have any work assignments
    person_has_work = {}
    for p in range(num_persons):
        has_any_task = False
        for s_idx, segment in enumerate(segments):
            for t_idx in segment.task_indices:
                # Person has work if they could potentially be assigned to any task
                has_any_task = True
                break
            if has_any_task:
                break
        person_has_work[p] = has_any_task

    for p in range(num_persons):
        for s_idx, segment in enumerate(segments):
            busy_vars = []

            # Use task_active variables instead of raw assignment/capability vars
            for t_idx in segment.task_indices:
                if (p, s_idx, t_idx) in task_active_vars:
                    busy_vars.append(task_active_vars[(p, s_idx, t_idx)])

            for k_idx in segment.transfer_indices:
                busy_vars.append(y[p, k_idx])

            # Check availability first!
            if not availability[p][s_idx]:
                # Person is unavailable - cannot be idle or get breaks
                model.Add(idle[p, s_idx] == 0)
            elif busy_vars:
                busy_expr = sum(busy_vars)
                # Force idle to be exactly the inverse of busy
                model.Add(idle[p, s_idx] == 1 - busy_expr)
            elif person_has_work[p]:
                # Person has work in general and is available, so they can be idle between tasks
                # Force idle if available and not busy (to ensure breaks are counted)
                model.Add(idle[p, s_idx] == 1)
            else:
                # Person has no work
                model.Add(idle[p, s_idx] == 0)
    print("[OK] Idle segment tracking")

    # === BREAK QUALIFICATION ===
    # A break should only count if it's BETWEEN work periods, not before first work or after last work
    for p in range(num_persons):
        for s_idx, segment in enumerate(segments):
            seg_duration = segment.end_time - segment.start_time

            if seg_duration < BREAK_THRESHOLD_MIN:
                model.Add(break_seg[p, s_idx] == 0)
            else:
                # Check if there's work before and after this segment
                has_work_before = model.NewBoolVar(f'has_work_before_p{p}_s{s_idx}')
                has_work_after = model.NewBoolVar(f'has_work_after_p{p}_s{s_idx}')

                # Work before: any segment s < s_idx where person is working
                work_before_vars = []
                for s_before in range(s_idx):
                    for t_idx in segments[s_before].task_indices:
                        if (p, s_before, t_idx) in task_active_vars:
                            work_before_vars.append(task_active_vars[(p, s_before, t_idx)])

                if work_before_vars:
                    model.AddMaxEquality(has_work_before, work_before_vars)
                else:
                    model.Add(has_work_before == 0)

                # Work after: any segment s > s_idx where person is working
                work_after_vars = []
                for s_after in range(s_idx + 1, num_segments):
                    for t_idx in segments[s_after].task_indices:
                        if (p, s_after, t_idx) in task_active_vars:
                            work_after_vars.append(task_active_vars[(p, s_after, t_idx)])

                if work_after_vars:
                    model.AddMaxEquality(has_work_after, work_after_vars)
                else:
                    model.Add(has_work_after == 0)

                # Break only counts if idle AND has work before AND has work after
                # break_seg = idle AND has_work_before AND has_work_after
                is_between_work = model.NewBoolVar(f'is_between_work_p{p}_s{s_idx}')
                model.AddMultiplicationEquality(is_between_work, [has_work_before, has_work_after])

                # break_seg = idle AND is_between_work
                model.AddMultiplicationEquality(break_seg[p, s_idx], [idle[p, s_idx], is_between_work])
    print("[OK] Break qualification (forced)")

    # === FATIGUE CALCULATION ===
    # Compute per-person initial fatigue offsets (scaled to integer domain)
    initial_fatigue_scaled = [round(persons[p].initial_fatigue * SCALE) for p in range(num_persons)]
    max_initial = max(initial_fatigue_scaled) if initial_fatigue_scaled else 0
    min_initial = min(initial_fatigue_scaled) if initial_fatigue_scaled else 0

    # Calculate safe bounds for fatigue
    max_positive_fatigue = sum(max(0, cost) for cost in task_fatigue_cost)
    max_negative_fatigue = sum(min(0, cost) for cost in task_fatigue_cost)
    # Sum of all possible per-segment break recoveries (each may differ by duration)
    max_break_recovery = sum(c for c in break_cost_per_seg if c < 0)

    fatigue_lower_bound = min_initial + max_negative_fatigue + max_break_recovery
    fatigue_upper_bound = max_initial + max_positive_fatigue

    # Ensure valid bounds: upper >= lower, and both allow 0 (person with no work = 0 fatigue)
    fatigue_lower_bound = min(fatigue_lower_bound, 0)
    fatigue_upper_bound = max(fatigue_upper_bound, 0)
    if fatigue_upper_bound < fatigue_lower_bound:
        fatigue_upper_bound = fatigue_lower_bound

    # For individual persons, fatigue can be as low as initial_fatigue (or 0 if no work)
    # but the computed lower bound might be negative (if there are negative-fatigue tasks)
    per_person_lower_bound = min(0, int(fatigue_lower_bound))

    fatigue = {}
    for p in range(num_persons):
        fatigue[p] = model.NewIntVar(
            per_person_lower_bound,
            int(fatigue_upper_bound),
            f'fatigue_p{p}'
        )

        model.Add(
            fatigue[p] == 
            initial_fatigue_scaled[p] +
            sum(task_fatigue_cost[t] * working[p, t] for t in range(num_tasks)) +
            sum(break_cost_per_seg[s] * break_seg[p, s] for s in range(num_segments))
        )
    print(f"[OK] Fatigue calculation (per-person bounds: [{per_person_lower_bound}, {fatigue_upper_bound}], initial_fatigue range: [{min_initial}, {max_initial}])")

    # === FATIGUE RANGE MINIMIZATION ===
    # F_min and F_max are the actual min/max fatigue values across all persons
    F_max = model.NewIntVar(int(fatigue_lower_bound), int(fatigue_upper_bound), 'F_max')
    F_min = model.NewIntVar(int(fatigue_lower_bound), int(fatigue_upper_bound), 'F_min')
    fatigue_range = model.NewIntVar(0, int(fatigue_upper_bound - fatigue_lower_bound), 'fatigue_range')

    # F_max is the maximum fatigue, F_min is the minimum fatigue
    model.AddMaxEquality(F_max, [fatigue[p] for p in range(num_persons)])
    model.AddMinEquality(F_min, [fatigue[p] for p in range(num_persons)])

    model.Add(fatigue_range == F_max - F_min)
    print("[OK] Fatigue range variables")

    # === HARD TASK COVERAGE ===
    # All normal (non-floating) tasks must be fully covered
    for t_idx in range(original_num_tasks):
        if t_idx not in task_to_floating:
            assumptions.enforce(
                model.Add(task_fully_covered[t_idx] == 1),
                f"task_coverage_{t_idx}",
                task_requirement_issue(tasks[t_idx], normalized_input),
            )
    print(f"[OK] Hard coverage for {original_num_tasks} normal tasks")

    # === OBJECTIVE: MINIMIZE FATIGUE RANGE ===
    model.Minimize(fatigue_range)
    print("\n[OBJECTIVE] Minimize fatigue range")

    # === SOLVE ===
    print(f"\n--- SOLVING (max {config.max_time_seconds}s) ---")
    solver = cp_model.CpSolver()
    solver.parameters.max_time_in_seconds = config.max_time_seconds
    if callback is None:
        callback = ProgressCallback(SCALE)
    else:
        callback._scale = SCALE
        callback._start = time.monotonic()
    status = solver.Solve(model, callback)

    solve_time = solver.WallTime()
    status_name = solver.StatusName(status)
    print(f"Status: {status_name}")
    print(f"Solve time: {solve_time:.2f}s")
    print(f"Solutions found: {len(callback.snapshots)}")

    if status == cp_model.INFEASIBLE:
        issues = assumptions.issues_for_infeasibility(solver)
        if not issues:
            issues = [core_fallback_issue(normalized_input)]
        errors.extend(issue.message for issue in issues)
        return _empty_result(
            "INFEASIBLE",
            solve_time,
            errors,
            persons,
            diagnostics_payload("infeasible", issues),
        )

    if status not in [cp_model.OPTIMAL, cp_model.FEASIBLE]:
        issue = DiagnosticIssue(
            code="SOLVER_UNDETERMINED",
            category="solver",
            message=(
                "The optimiser stopped before it could prove whether a valid "
                "assignment exists."
            ),
            facts=(("Solver status", status_name),),
            suggestions=("Run the optimisation again or increase the solver time limit.",),
        )
        errors.append(issue.message)
        return _empty_result(
            "UNKNOWN",
            solve_time,
            errors,
            persons,
            diagnostics_payload("undetermined", [issue]),
        )

    # === EXTRACT SOLUTION ===
    print("\n--- SOLUTION ---")

    # Extract fatigue values
    fatigue_per_person = {}
    breaks_per_person = {}

    for p_idx, person in enumerate(persons):
        fatigue_val = solver.Value(fatigue[p_idx]) / SCALE
        fatigue_per_person[person.id] = fatigue_val

        num_breaks = sum(solver.Value(break_seg[p_idx, s]) for s in range(num_segments))
        breaks_per_person[person.id] = num_breaks

    fatigue_min_val = min(fatigue_per_person.values())
    fatigue_max_val = max(fatigue_per_person.values())
    fatigue_range_val = fatigue_max_val - fatigue_min_val

    print(f"\nFatigue: min={fatigue_min_val:.2f}, max={fatigue_max_val:.2f}, range={fatigue_range_val:.2f}")

    print("\n--- FATIGUE PER PERSON ---")
    for person in persons:
        fat = fatigue_per_person[person.id]
        brk = breaks_per_person[person.id]
        print(f"Person {person.id}: fatigue={fat:.2f}, breaks={brk}")

    # Extract assignments
    assignments = {}
    capability_assignments = {}

    print("\n--- TASK ASSIGNMENTS ---")
    for t_idx, task in enumerate(tasks):
        is_covered = solver.Value(task_fully_covered[t_idx]) == 1

        # Check if this is an unchosen floating candidate
        if t_idx in task_to_floating:
            ft_idx = task_to_floating[t_idx]
            if solver.Value(float_choice[ft_idx, t_idx]) == 0:
                continue  # Skip unchosen candidates

        assigned_persons = []
        for p_idx, person in enumerate(persons):
            if solver.Value(assigned[p_idx, t_idx]) == 1:
                assigned_persons.append(person.id)

        if assigned_persons:
            assignments[task.id] = assigned_persons
            print(f"Task {task.id} ({task.name}): assigned={assigned_persons}")

        if task.preassigned_person_ids and not task.requirements:
            expected = {pid for pid in task.preassigned_person_ids if pid in person_to_idx}
            unexpected = [pid for pid in assigned_persons if pid not in expected]
            if unexpected:
                print(
                    f"WARNING: Direct-only task {task.id} ({task.name}) "
                    f"has unexpected direct assignments: {unexpected}"
                )

        for cap_name, req in task.requirements.items():
            if cap_name in capability_to_idx:
                c_idx = capability_to_idx[cap_name]
                cap_persons = []
                for p_idx, person in enumerate(persons):
                    if solver.Value(x[p_idx, t_idx, c_idx]) == 1:
                        cap_persons.append(person.id)

                if cap_persons:
                    capability_assignments[(task.id, cap_name)] = cap_persons
                    print(f"  {cap_name}: {cap_persons}")

    # Extract transfer assignments
    transfer_assignments = {}
    if transfers:
        print("\n--- TRANSFER ASSIGNMENTS ---")
        for k, transfer in enumerate(transfers):
            boarding_persons = []
            for p_idx, person in enumerate(persons):
                if solver.Value(y[p_idx, k]) == 1:
                    boarding_persons.append(person.id)

            if boarding_persons:
                transfer_assignments[transfer.id] = boarding_persons
                print(f"Transfer {transfer.id} ({transfer.from_location_id} -> {transfer.to_location_id}): assigned={boarding_persons}")

    # Build task details mapping for response builder
    task_details = {}
    for t_idx, task in enumerate(tasks):
        original_id = getattr(task, 'original_floating_task_id', task.id)
        resolved_location_id = task.location_id

        # For any-location tasks, resolve the chosen location from the solver
        if task.location_id is None and t_idx in any_location_task_indices:
            if solver.Value(task_fully_covered[t_idx]) == 1:
                for l in range(num_locations):
                    if solver.Value(task_loc_choice[t_idx, l]) == 1:
                        resolved_location_id = locations[l]
                        print(f"  Any-location task '{task.name}' (ID: {task.id}): placed at location {resolved_location_id}")
                        break

        task_details[task.id] = {
            'start_time': task.start_time,
            'end_time': task.end_time,
            'location_id': resolved_location_id,
            'original_id': original_id
        }

    # Add transfer details
    for transfer in transfers:
        task_details[transfer.id] = {
            'start_time': transfer.depart_time,
            'end_time': transfer.arrive_time,
            'location_id': transfer.from_location_id,  # From location
            'original_id': transfer.id,
            'is_transfer': True,
            'to_location_id': transfer.to_location_id
        }

    # === BUILD FIELD ASSIGNMENTS ===
    # Maps task/transfer id -> {field_id -> [person_ids]}
    field_assignments = {}

    # Build person capabilities lookup
    person_caps_map = {person.id: set(person.capabilities) for person in persons}

    # Field assignments for regular tasks (using capability_assignments from x variables)
    for t_idx, task in enumerate(tasks):
        if not getattr(task, 'field_requirements', None):
            continue
        task_field_map = {}
        assigned_to_field = set()

        for field_id, field_caps in task.field_requirements.items():
            field_persons = []
            for cap_name, count in field_caps.items():
                # Get persons assigned to this capability for this task via x[p,t,c]
                cap_persons = capability_assignments.get((task.id, cap_name), [])
                for pid in cap_persons:
                    if pid not in assigned_to_field:
                        field_persons.append(pid)
                        assigned_to_field.add(pid)
            if field_persons:
                task_field_map[field_id] = field_persons

        # Any remaining assigned persons not matched to a capability field (e.g. preassigned)
        all_assigned = assignments.get(task.id, [])
        unassigned = [pid for pid in all_assigned if pid not in assigned_to_field]
        if unassigned:
            task_field_map['field_Assigned'] = unassigned

        if task_field_map:
            field_assignments[task.id] = task_field_map

    # Field assignments for transfers (using y variables + greedy capability matching)
    for k, transfer in enumerate(transfers):
        boarding = transfer_assignments.get(transfer.id, [])
        if not boarding:
            continue

        has_direct_person_fields = bool(getattr(transfer, "person_field_assignments", None))
        has_capability_fields = bool(getattr(transfer, "field_requirements", None))
        transferee_fid = getattr(transfer, 'transferee_field_id', None)
        if not has_direct_person_fields and not has_capability_fields and not transferee_fid:
            continue

        transfer_field_map = {}
        assigned_to_field = set()

        # Direct persons_list fields are fixed by the normaliser. Keep these
        # field assignments separate from capability staff and dynamic
        # transferees so the UI reflects what the organiser selected.
        for field_id, person_ids in getattr(transfer, "person_field_assignments", {}).items():
            direct_persons = [pid for pid in person_ids if pid in boarding]
            if direct_persons:
                transfer_field_map[field_id] = direct_persons
                assigned_to_field.update(direct_persons)

        # First pass: assign boarding persons to capability-required fields
        for field_id, field_caps in transfer.field_requirements.items():
            field_persons = []
            for cap_name, count in field_caps.items():
                # Find boarding persons with this capability (not yet assigned to a field)
                qualified = [pid for pid in boarding
                             if pid not in assigned_to_field
                             and cap_name in person_caps_map.get(pid, set())]
                for pid in qualified[:count]:
                    field_persons.append(pid)
                    assigned_to_field.add(pid)
            if field_persons:
                transfer_field_map[field_id] = field_persons

        # Remaining boarding persons → transferee field
        remaining = [pid for pid in boarding if pid not in assigned_to_field]
        if remaining:
            if transferee_fid:
                transfer_field_map[transferee_fid] = remaining
            else:
                transfer_field_map['_transferee'] = remaining

        if transfer_field_map:
            field_assignments[transfer.id] = transfer_field_map

    if field_assignments:
        print("\n--- FIELD ASSIGNMENTS ---")
        for tid, fmap in field_assignments.items():
            print(f"  Task/Transfer {tid}: {fmap}")

    print("\n" + "=" * 80)

    return OptimizationResult(
        status=status_name,
        assignments=assignments,
        capability_assignments=capability_assignments,
        transfer_assignments=transfer_assignments,
        fatigue_per_person=fatigue_per_person,
        breaks_per_person=breaks_per_person,
        fatigue_min=fatigue_min_val,
        fatigue_max=fatigue_max_val,
        fatigue_range=fatigue_range_val,
        solve_time=solve_time,
        errors=errors,
        task_details=task_details,
        field_assignments=field_assignments,
        progress_snapshots=callback.snapshots,
        diagnostics=diagnostics_payload("feasible", []),
    )

Flow Checker

flow_checker

Flow Checker - Validates task assignments and resource flow for capabilities using CP-SAT.

This module checks if tasks can be feasibly assigned given: - Available persons with specific capabilities - Task requirements (location, time, capability needs) - Transfer constraints between locations

Uses Google OR-Tools CP-SAT solver to determine satisfiability.

NormPerson dataclass

Normalised person input used by the flow checker and optimiser.

Source code in compute/src/flow_checker.py
@dataclass
class NormPerson:
    """Normalised person input used by the flow checker and optimiser."""

    id: int
    home_location_id: Optional[int]
    capabilities: List[str]  # canonical capability ids (machine_names like "is_ho")

    # Maximum working minutes per day (None = no limit)
    max_work_minutes_per_day: Optional[int] = None

    # List of unavailable intervals (start_time, end_time) in minutes since midnight
    # Example: [(0, 480), (1320, 1440)] means unavailable 00:00-08:00 and 22:00-24:00
    unavailable_intervals: List[Tuple[int, int]] = field(default_factory=list)

    # Initial fatigue carry-over from previous day (0.0 = fresh start)
    initial_fatigue: float = 0.0
    name: str = ""

NormTask dataclass

Normalised fixed task with timing, location, and capability demand.

Source code in compute/src/flow_checker.py
@dataclass
class NormTask:
    """Normalised fixed task with timing, location, and capability demand."""

    id: int
    name: str
    location_id: Optional[int]  # None means "any location"  -  solver picks
    start_time: int  # minutes since midnight (e.g., 420 = 07:00)
    end_time: int    # minutes since midnight (e.g., 480 = 08:00)
    requirements: Dict[str, int]  # capability_id -> count
    preassigned_person_ids: List[int] = field(default_factory=list)  # List of fixed assigned persons

    # Optional: for tasks that move people between locations (like transfers)
    from_location_id: Optional[int] = None  # Starting location (defaults to location_id)
    to_location_id: Optional[int] = None    # Ending location (defaults to location_id)

    # Per-field requirement tracking: field_id -> {cap_name: count}
    field_requirements: Dict[str, Dict[str, int]] = field(default_factory=dict)
    counts_towards_work_time: bool = True

NormTransfer dataclass

Normalised transfer leg that can move people between locations.

Source code in compute/src/flow_checker.py
@dataclass
class NormTransfer:
    """Normalised transfer leg that can move people between locations."""

    id: int
    from_location_id: int
    to_location_id: int
    depart_time: int  # minutes since midnight
    arrive_time: int  # minutes since midnight
    capacity: int
    requirements: Dict[str, int]  # capability_id -> count (same as tasks)
    optional_capacity_slots: int = 0  # Dynamic passenger seats beyond locked passengers and capability staff
    field_requirements: Dict[str, Dict[str, int]] = field(default_factory=dict)  # field_id -> {cap_name: count}
    transferee_field_id: Optional[str] = None  # field_id for the transferee field
    locked_person_ids: List[int] = field(default_factory=list)  # Direct transfer passengers from persons_list fields
    person_field_assignments: Dict[str, List[int]] = field(default_factory=dict)  # field_id -> direct passenger ids
    counts_towards_work_time: bool = True

NormFloatingTask dataclass

Normalised task that may be scheduled within a bounded time window.

Source code in compute/src/flow_checker.py
@dataclass
class NormFloatingTask:
    """Normalised task that may be scheduled within a bounded time window."""

    id: int
    name: str
    location_id: Optional[int]  # None means "any location"  -  solver picks

    # Time window in which this task must be done
    window_start_time: int  # minutes since midnight
    window_end_time: int    # minutes since midnight

    duration: int           # minimum duration in minutes

    # capability_id -> count (same semantics as NormTask.requirements)
    requirements: Dict[str, int]

    # Optional preassigned organisers (list of fixed assigned persons)
    preassigned_person_ids: List[int] = field(default_factory=list)
    counts_towards_work_time: bool = True

NormalizedFlowInput dataclass

Complete normalised schedule input consumed by flow feasibility checks.

Source code in compute/src/flow_checker.py
@dataclass
class NormalizedFlowInput:
    """Complete normalised schedule input consumed by flow feasibility checks."""

    persons: List[NormPerson]
    tasks: List[NormTask]        # "normal" tasks that consume capability
    transfers: List[NormTransfer]
    errors: List[str]            # warnings/errors from parsing
    floating_tasks: List[NormFloatingTask] = field(default_factory=list)  # tasks that can float within a time window
    capability_names: Dict[str, str] = field(default_factory=dict)
    location_names: Dict[int, str] = field(default_factory=dict)

TimeSegment dataclass

Represents a continuous time period where task set is constant.

Source code in compute/src/flow_checker.py
@dataclass
class TimeSegment:
    """Represents a continuous time period where task set is constant."""
    start_time: int  # minutes since midnight
    end_time: int    # minutes since midnight
    task_indices: List[int]  # indices of tasks active in this segment
    transfer_indices: List[int]  # indices of transfers active in this segment

minutes_to_time_str

minutes_to_time_str(minutes: int) -> str

Convert minutes since midnight to HH:MM string.

Source code in compute/src/flow_checker.py
def minutes_to_time_str(minutes: int) -> str:
    """Convert minutes since midnight to HH:MM string."""
    hours = minutes // 60
    mins = minutes % 60
    return f"{hours:02d}:{mins:02d}"

generate_time_segments

generate_time_segments(tasks: List[NormTask], transfers: List[NormTransfer], floating_tasks: List[NormFloatingTask]) -> List[TimeSegment]

Generate time segments from task, transfer, and floating task time boundaries.

Each time segment is a period where the set of active tasks/transfers is constant. Segment boundaries occur at every task/transfer start or end time.

Parameters:

Name Type Description Default
tasks List[NormTask]

List of normalised tasks

required
transfers List[NormTransfer]

List of normalised transfers

required
floating_tasks List[NormFloatingTask]

List of floating tasks with time windows

required

Returns:

Type Description
List[TimeSegment]

List of TimeSegment objects in chronological order

Source code in compute/src/flow_checker.py
def generate_time_segments(
    tasks: List[NormTask], 
    transfers: List[NormTransfer],
    floating_tasks: List[NormFloatingTask]
) -> List[TimeSegment]:
    """
    Generate time segments from task, transfer, and floating task time boundaries.

    Each time segment is a period where the set of active tasks/transfers is constant.
    Segment boundaries occur at every task/transfer start or end time.

    Args:
        tasks: List of normalised tasks
        transfers: List of normalised transfers
        floating_tasks: List of floating tasks with time windows

    Returns:
        List of TimeSegment objects in chronological order
    """
    # Collect all unique time points
    time_points = set()
    for task in tasks:
        time_points.add(task.start_time)
        time_points.add(task.end_time)
    for transfer in transfers:
        time_points.add(transfer.depart_time)
        time_points.add(transfer.arrive_time)

    print(f"DEBUG generate_time_segments: {len(floating_tasks)} floating tasks")
    for ft in floating_tasks:
        print(f"  Floating task '{ft.name}': window {ft.window_start_time}-{ft.window_end_time}, duration {ft.duration}")
        time_points.add(ft.window_start_time)
        time_points.add(ft.window_end_time)
        # Add internal time points based on duration to create finer-grained segments
        # This allows multiple floating tasks with the same duration to be scheduled
        # sequentially within the same window (e.g., 10:00, 11:00, 12:00 for 1-hour tasks)
        if ft.duration > 0:
            t = ft.window_start_time
            while t + ft.duration <= ft.window_end_time:
                time_points.add(t)
                time_points.add(t + ft.duration)
                t += ft.duration
                print(f"    Added time point: {t - ft.duration} and {t}")

    # Sort time points
    sorted_times = sorted(time_points)

    if len(sorted_times) < 2:
        return []

    # Create segments between consecutive time points
    segments = []
    for i in range(len(sorted_times) - 1):
        start = sorted_times[i]
        end = sorted_times[i + 1]

        # Find which tasks are active in this segment
        # A task is active if: start_time <= segment_start < task.end_time
        active_tasks = []
        for idx, task in enumerate(tasks):
            if task.start_time <= start < task.end_time:
                active_tasks.append(idx)

        # Find which transfers are active in this segment
        # A transfer is active during [depart_time, arrive_time)
        active_transfers = []
        for idx, transfer in enumerate(transfers):
            if transfer.depart_time <= start < transfer.arrive_time:
                active_transfers.append(idx)

        segments.append(TimeSegment(
            start_time=start,
            end_time=end,
            task_indices=active_tasks,
            transfer_indices=active_transfers
        ))

    return segments

check_flow

check_flow(normalized_input: NormalizedFlowInput, max_time_seconds: float = 30.0, *, include_diagnostics: bool = False) -> List[str] | Tuple[List[str], Dict[str, Any]]

Check if the given tasks can be feasibly assigned using CP-SAT.

Parameters:

Name Type Description Default
normalized_input NormalizedFlowInput

NormalizedFlowInput containing all normalised data

required
max_time_seconds float

Solver time limit in seconds (default 30.0)

30.0

Returns:

Type Description
List[str] | Tuple[List[str], Dict[str, Any]]

List of error messages by default. When include_diagnostics is true,

List[str] | Tuple[List[str], Dict[str, Any]]

return (errors, diagnostics) for API callers.

Source code in compute/src/flow_checker.py
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
def check_flow(
    normalized_input: NormalizedFlowInput,
    max_time_seconds: float = 30.0,
    *,
    include_diagnostics: bool = False,
) -> List[str] | Tuple[List[str], Dict[str, Any]]:
    """
    Check if the given tasks can be feasibly assigned using CP-SAT.

    Args:
        normalized_input: NormalizedFlowInput containing all normalised data
        max_time_seconds: Solver time limit in seconds (default 30.0)

    Returns:
        List of error messages by default. When ``include_diagnostics`` is true,
        return ``(errors, diagnostics)`` for API callers.
    """
    errors = []

    def result(
        current_errors: List[str],
        status: str,
        issues: List[DiagnosticIssue],
    ) -> List[str] | Tuple[List[str], Dict[str, Any]]:
        payload = diagnostics_payload(status, issues)
        if include_diagnostics:
            return current_errors, payload
        return current_errors

    print("=" * 80)
    print("FLOW CHECKER - CT-SAT SOLVER")
    print("=" * 80)

    # Extract data
    persons = normalized_input.persons
    tasks = normalized_input.tasks
    transfers = normalized_input.transfers
    floating_tasks = getattr(normalized_input, "floating_tasks", [])

    preflight = preflight_issues(normalized_input)
    if preflight:
        return result(
            [issue.message for issue in preflight],
            "invalid_input",
            preflight,
        )

    if not tasks and not transfers and not floating_tasks:
        return result([], "feasible", [])  # Nothing to check

    # Generate time segments (including floating task windows)
    segments = generate_time_segments(tasks, transfers, floating_tasks)

    print(f"\n--- TIME SEGMENTS ({len(segments)}) ---")
    for i, seg in enumerate(segments):
        print(f"Segment {i}: {minutes_to_time_str(seg.start_time)} -> {minutes_to_time_str(seg.end_time)}")
        print(f"  Active tasks: {seg.task_indices}")
        print(f"  Active transfers: {seg.transfer_indices}")

    # --- EXPAND FLOATING TASKS INTO CANDIDATE FIXED TASKS ---
    floating_candidates: Dict[int, List[int]] = {}  # ft_idx -> list of task indices
    original_num_tasks = len(tasks)

    if floating_tasks:
        print(f"\n--- EXPANDING {len(floating_tasks)} FLOATING TASKS ---")

    for ft_idx, ft in enumerate(floating_tasks):
        floating_candidates[ft_idx] = []

        for s_idx, seg in enumerate(segments):
            seg_start = seg.start_time
            seg_end = seg.end_time
            seg_len = seg_end - seg_start

            # Only consider starting segments fully inside the floating window
            if seg_start < ft.window_start_time:
                continue

            # Check if the task can fit starting at this segment
            # It may span multiple consecutive segments
            task_end = seg_start + ft.duration

            # Task must end within the floating window
            if task_end > ft.window_end_time:
                continue

            # Create a candidate NormTask that starts at this segment
            # (it may span multiple consecutive segments)
            candidate_task = NormTask(
                id=ft.id,
                name=f"{ft.name} [floating@seg{s_idx}]",
                location_id=ft.location_id,
                start_time=seg_start,
                end_time=task_end,
                requirements=dict(ft.requirements),
                preassigned_person_ids=ft.preassigned_person_ids,
                counts_towards_work_time=(
                    getattr(ft, "counts_towards_work_time", True) is not False
                ),
            )

            tasks.append(candidate_task)
            new_task_idx = len(tasks) - 1
            floating_candidates[ft_idx].append(new_task_idx)
            print(f"  Floating task '{ft.name}' (ID: {ft.id}) -> candidate at segment {s_idx}: task index {new_task_idx}")

        # Check if any feasible slots exist
        if not floating_candidates[ft_idx]:
            errors.append(
                f"Floating task '{ft.name}' (ID: {ft.id}) has no feasible time slot within its window "
                f"({minutes_to_time_str(ft.window_start_time)}-{minutes_to_time_str(ft.window_end_time)}, "
                f"duration: {ft.duration} min)."
            )

    # Build reverse map: task_idx -> floating task index (ft_idx)
    task_to_floating: Dict[int, int] = {}
    for ft_idx, cand_task_indices in floating_candidates.items():
        for t_idx in cand_task_indices:
            task_to_floating[t_idx] = ft_idx

    # --- REBUILD SEGMENT TASK INDICES AFTER FLOATING EXPANSION ---
    # At this point, `tasks` now includes both original tasks and floating candidates.
    # We must update each segment's task_indices so that:
    #   - capability/location constraints
    #   - assigned-person constraints
    #   - no-double-booking
    # all "see" the floating candidates.
    # 
    # A task is active in a segment if the segment starts during the task's execution.
    # This matches the optimiser's task_segments mapping.
    for seg in segments:
        seg.task_indices = []

    for t_idx, task in enumerate(tasks):
        for s_idx, seg in enumerate(segments):
            # A task is active in every segment it spans
            if task.start_time <= seg.start_time < task.end_time:
                seg.task_indices.append(t_idx)

    if floating_tasks:
        print(f"\n--- REBUILT SEGMENT TASK INDICES ---")
        for i, seg in enumerate(segments):
            print(f"Segment {i}: {minutes_to_time_str(seg.start_time)} -> {minutes_to_time_str(seg.end_time)}")
            print(f"  Active tasks: {seg.task_indices}")

    # Collect all unique capabilities and locations
    all_capabilities = set()
    all_locations = set()

    for person in persons:
        all_capabilities.update(person.capabilities)
        if person.home_location_id is not None:
            all_locations.add(person.home_location_id)

    for task in tasks:
        all_capabilities.update(task.requirements.keys())
        if task.location_id is not None:
            all_locations.add(task.location_id)
        if task.from_location_id is not None:
            all_locations.add(task.from_location_id)
        if task.to_location_id is not None:
            all_locations.add(task.to_location_id)

    for transfer in transfers:
        all_capabilities.update(transfer.requirements.keys())
        all_locations.add(transfer.from_location_id)
        all_locations.add(transfer.to_location_id)

    capabilities = sorted(all_capabilities)
    locations = sorted(all_locations)

    # Create index mappings
    person_to_idx = {p.id: i for i, p in enumerate(persons)}
    capability_to_idx = {c: i for i, c in enumerate(capabilities)}
    location_to_idx = {loc: i for i, loc in enumerate(locations)}
    idx_to_location = {i: loc for i, loc in enumerate(locations)}

    num_persons = len(persons)
    num_tasks = len(tasks)
    num_capabilities = len(capabilities)
    num_locations = len(locations)
    num_segments = len(segments)
    num_transfers = len(transfers)

    print(f"\n--- PROBLEM SIZE ---")
    print(f"Persons: {num_persons}")
    print(f"Tasks: {num_tasks}")
    print(f"Transfers: {num_transfers}")
    print(f"Capabilities: {num_capabilities}")
    print(f"Locations: {num_locations}")
    print(f"Segments: {num_segments}")

    # Map each task index to the segment index where it is active (by start_time)
    task_segment_idx: Dict[int, Optional[int]] = {}
    for t_idx, task in enumerate(tasks):
        seg_for_task = None
        for s_idx, seg in enumerate(segments):
            if t_idx in seg.task_indices:
                seg_for_task = s_idx
                break
        task_segment_idx[t_idx] = seg_for_task
    task_segment_indices: Dict[int, List[int]] = {
        t_idx: [
            s_idx
            for s_idx, seg in enumerate(segments)
            if t_idx in seg.task_indices
        ]
        for t_idx in range(num_tasks)
    }

    # availability[p][s] = True if person p is available in segment s
    availability = [[True for _ in range(num_segments)] for _ in range(num_persons)]

    for p_idx, person in enumerate(persons):
        for s_idx, seg in enumerate(segments):
            seg_start = seg.start_time
            seg_end = seg.end_time
            # Mark as unavailable if this segment overlaps any unavailable interval
            for (ua_start, ua_end) in person.unavailable_intervals:
                # overlap if ua_start < seg_end and seg_start < ua_end
                if ua_start < seg_end and seg_start < ua_end:
                    availability[p_idx][s_idx] = False
                    break

    def person_available_for_task(p_idx: int, t_idx: int) -> bool:
        active_segments = task_segment_indices.get(t_idx, [])
        return bool(active_segments) and all(availability[p_idx][s_idx] for s_idx in active_segments)

    # Build CP-SAT model
    model = cp_model.CpModel()

    # Decision variables
    # x[p][t][c]: person p covers capability c for task t
    x = {}
    for p in range(num_persons):
        for t in range(num_tasks):
            for c in range(num_capabilities):
                x[p, t, c] = model.NewBoolVar(f'x_p{p}_t{t}_c{c}')

    # assigned[p][t]: person p is the assigned person for task t
    assigned = {}
    for p in range(num_persons):
        for t in range(num_tasks):
            assigned[p, t] = model.NewBoolVar(f'assigned_p{p}_t{t}')

    # z[p][s][l]: person p is at location l in segment s
    z = {}
    for p in range(num_persons):
        for s in range(num_segments):
            for l in range(num_locations):
                z[p, s, l] = model.NewBoolVar(f'z_p{p}_s{s}_l{l}')

    # y[p][k]: person p uses transfer k
    y = {}
    for p in range(num_persons):
        for k in range(num_transfers):
            y[p, k] = model.NewBoolVar(f'y_p{p}_k{k}')

    # task_fully_covered[t]: task t has all capability requirements satisfied
    task_fully_covered = []
    for t in range(num_tasks):
        var = model.NewBoolVar(f'task_fully_covered_{t}')
        task_fully_covered.append(var)

    # float_choice[ft_idx, t_idx]: choose which candidate segment for floating task ft_idx
    float_choice = {}
    for ft_idx, cand_task_indices in floating_candidates.items():
        for t_idx in cand_task_indices:
            float_choice[ft_idx, t_idx] = model.NewBoolVar(f'float_choice_ft{ft_idx}_t{t_idx}')

    # Duration in minutes for each task
    task_durations = [0] * num_tasks
    for t_idx, task in enumerate(tasks):
        task_durations[t_idx] = task.end_time - task.start_time
    transfer_durations = []
    for transfer in transfers:
        duration = transfer.arrive_time - transfer.depart_time
        if duration < 0:
            duration += 24 * 60
        transfer_durations.append(max(0, duration))

    # working[p, t]: person p works on task t in any role (assigned or capability)
    working = {}
    for p in range(num_persons):
        for t in range(num_tasks):
            working[p, t] = model.NewBoolVar(f'working_p{p}_t{t}')

    # Link working to assigned and x
    for p in range(num_persons):
        for t in range(num_tasks):
            # If person is assigned or has any capability on this task, they are working on it
            model.Add(working[p, t] >= assigned[p, t])
            model.Add(working[p, t] >= sum(x[p, t, c] for c in range(num_capabilities)))
            # At most one role per task (tighter constraint than cross-segment no-double-booking)
            model.Add(assigned[p, t] + sum(x[p, t, c] for c in range(num_capabilities)) <= 1)

    # Total work time per person in minutes
    work_task_durations = [
        duration
        if getattr(task, "counts_towards_work_time", True) is not False
        else 0
        for task, duration in zip(tasks, task_durations)
    ]
    work_transfer_durations = [
        duration
        if getattr(transfer, "counts_towards_work_time", True) is not False
        else 0
        for transfer, duration in zip(transfers, transfer_durations)
    ]
    max_possible_time = (sum(work_task_durations) if work_task_durations else 0) + (
        sum(work_transfer_durations) if work_transfer_durations else 0
    )
    work_time = {}
    for p in range(num_persons):
        work_time[p] = model.NewIntVar(0, max_possible_time, f'work_time_p{p}')
        model.Add(
            work_time[p] ==
            sum(work_task_durations[t] * working[p, t] for t in range(num_tasks)) +
            sum(work_transfer_durations[k] * y[p, k] for k in range(num_transfers))
        )

    # Enforce maximum work time per person if specified
    for p_idx, person in enumerate(persons):
        if person.max_work_minutes_per_day is not None:
            model.Add(work_time[p_idx] <= int(person.max_work_minutes_per_day))

    print("\n--- ADDING CONSTRAINTS ---")

    # 4.1 Initial location (first segment), with unavailability-aware teleport
    if num_segments > 0:
        first_seg_start = segments[0].start_time

        for p_idx, person in enumerate(persons):
            # Check if the person had any unavailability interval that ended before
            # the first segment starts. If so, we assume they could have travelled
            # anywhere during that off-time, so we DO NOT fix their location in the
            # first segment.
            had_unavailability_before_first_segment = False
            for (ua_start, ua_end) in getattr(person, "unavailable_intervals", []):
                if ua_end <= first_seg_start:
                    had_unavailability_before_first_segment = True
                    break

            # Check if person is preassigned to any task in first segment
            preassigned_first_segment_location = None
            for t_idx, task in enumerate(tasks):
                if person.id in task.preassigned_person_ids:
                    # Check if this task is active in first segment
                    if t_idx in segments[0].task_indices:
                        preassigned_first_segment_location = task.location_id
                        break

            if preassigned_first_segment_location is not None:
                # Person is preassigned to a task in first segment - must start at task location
                task_loc_idx = location_to_idx[preassigned_first_segment_location]
                model.Add(z[p_idx, 0, task_loc_idx] == 1)
                for l in range(num_locations):
                    if l != task_loc_idx:
                        model.Add(z[p_idx, 0, l] == 0)
            elif (
                not had_unavailability_before_first_segment
                and person.home_location_id is not None
                and person.home_location_id in location_to_idx
            ):
                # Person has not been "off-grid" yet => start at home location
                home_loc_idx = location_to_idx[person.home_location_id]
                model.Add(z[p_idx, 0, home_loc_idx] == 1)
                for l in range(num_locations):
                    if l != home_loc_idx:
                        model.Add(z[p_idx, 0, l] == 0)
            else:
                # Person had an unavailability window before we ever 'see' them in a segment.
                # We do NOT constrain their starting location here. Combined with:
                #   - 4.2 exactly one location per person per segment
                #   - availability & assignment constraints
                # this means:
                #   - They can reappear in ANY one location in the first segment.
                #   - This matches the notion "during unavailability, they can travel
                #     wherever, and it's their responsibility to be at the right place
                #     when they become available again."
                pass
        print("[OK] 4.1 Initial locations (unavailability-aware)")

    # 4.2 Exactly one location per person per segment
    for p in range(num_persons):
        for s in range(num_segments):
            model.Add(sum(z[p, s, l] for l in range(num_locations)) == 1)
    print("[OK] 4.2 One location per person per segment")

    # 4.3 Location propagation via transfers and moving tasks, respecting unavailability
    for p in range(num_persons):
        for s in range(1, num_segments):
            for l in range(num_locations):
                # If the person is available in both previous and current segment,
                # enforce normal propagation (stay or arrive by transfer/moving task).
                if availability[p][s-1] and availability[p][s]:
                    incoming_transfers = []
                    for k, transfer in enumerate(transfers):
                        to_loc_idx = location_to_idx[transfer.to_location_id]
                        # Transfer ends when it reaches arrive_time
                        # Check if this segment starts at the transfer's arrival time
                        if to_loc_idx == l and segments[s].start_time == transfer.arrive_time:
                            incoming_transfers.append(k)

                    # Also check for tasks that move people to this location
                    incoming_moving_tasks = []
                    for t_idx, task in enumerate(tasks):
                        if task.to_location_id is not None:
                            to_loc_idx = location_to_idx[task.to_location_id]
                            # Task ends at end_time, so person arrives at to_location at end_time
                            if to_loc_idx == l and segments[s].start_time == task.end_time:
                                incoming_moving_tasks.append(t_idx)

                    # Person can be at (s,l) if they:
                    # 1. Were at (s-1,l) (stayed)
                    # 2. Arrived via transfer
                    # 3. Arrived via a moving task
                    model.Add(
                        z[p, s, l] <= z[p, s-1, l] + 
                        sum(y[p, k] for k in incoming_transfers) +
                        sum(working[p, t] for t in incoming_moving_tasks)
                    )
                else:
                    # If the person is unavailable in s-1 or s, we do NOT constrain z[p, s, l]
                    # by z[p, s-1, l]. Combined with the "exactly one location per segment"
                    # constraint and the fact that we forbid tasks/transfers when unavailable,
                    # this means:
                    #   - During unavailability, their location is irrelevant.
                    #   - As soon as they become available again, they may be assigned
                    #     to ANY single location.
                    pass
    print("[OK] 4.3 Location propagation")

    # 4.4 Transfer boarding and arrival conditions
    for p_idx, person in enumerate(persons):
        for k, transfer in enumerate(transfers):
            # Find segment indices where transfer departs and arrives
            depart_segment = None
            arrive_segment = None
            for s_idx, seg in enumerate(segments):
                if seg.start_time == transfer.depart_time:
                    depart_segment = s_idx
                if seg.start_time == transfer.arrive_time:
                    arrive_segment = s_idx

            if depart_segment is not None:
                from_loc_idx = location_to_idx[transfer.from_location_id]
                to_loc_idx = location_to_idx[transfer.to_location_id]

                # Must be at origin location to board
                model.Add(y[p_idx, k] <= z[p_idx, depart_segment, from_loc_idx])

                # If you board the transfer, you MUST be at destination when it arrives
                if arrive_segment is not None:
                    # y[p,k] => z[p,arrive,to_loc] (implication)
                    # Equivalent to: z[p,arrive,to_loc] >= y[p,k]
                    model.Add(z[p_idx, arrive_segment, to_loc_idx] >= y[p_idx, k])

                # Cannot use transfer if unavailable at departure or arrival segment
                if not availability[p_idx][depart_segment]:
                    model.Add(y[p_idx, k] == 0)
                if arrive_segment is not None and not availability[p_idx][arrive_segment]:
                    model.Add(y[p_idx, k] == 0)
    print("[OK] 4.4 Transfer boarding")

    # 4.5 Transfer capacity and capability requirements
    for k, transfer in enumerate(transfers):
        locked_person_indices = [
            person_to_idx[person_id]
            for person_id in getattr(transfer, "locked_person_ids", [])
            if person_id in person_to_idx
        ]
        if locked_person_indices:
            print(
                f"  Transfer {transfer.id}: Direct passengers locked to: "
                f"{getattr(transfer, 'locked_person_ids', [])}"
            )
            for p_idx in locked_person_indices:
                model.Add(y[p_idx, k] == 1)

        # Total capacity constraint
        if transfer.capacity is not None and transfer.capacity < 999:
            model.Add(sum(y[p, k] for p in range(num_persons)) <= transfer.capacity)

        # Required capability constraints - only need 'count' people with each capability
        # The rest can be anyone (dynamic allocation slots)
        for cap_name, count in transfer.requirements.items():
            if cap_name in capability_to_idx:
                # Count how many people boarding have this capability
                people_with_cap = []
                for p in range(num_persons):
                    person = persons[p]
                    if cap_name in person.capabilities:
                        people_with_cap.append(p)

                # At least 'count' people with this capability must board
                if people_with_cap:
                    model.Add(sum(y[p, k] for p in people_with_cap) >= count)
    print("[OK] 4.5 Transfer capacity and requirements")

    # 4.6 Task feasibility
    # 4.6.0 Any-location auxiliary variables
    # For tasks with location_id == None, the solver chooses the location.
    # task_loc_choice[t, l] = 1 iff task t takes place at location l
    task_loc_choice = {}
    any_location_task_indices = [t_idx for t_idx, task in enumerate(tasks) if task.location_id is None
                                 and not (task.from_location_id is not None and task.to_location_id is not None)]

    for t_idx in any_location_task_indices:
        for l in range(num_locations):
            task_loc_choice[t_idx, l] = model.NewBoolVar(f'task_loc_choice_t{t_idx}_l{l}')

        # Task takes place at exactly one location (if covered)
        model.Add(
            sum(task_loc_choice[t_idx, l] for l in range(num_locations))
            == task_fully_covered[t_idx]
        )
    print(f"[OK] 4.6.0 Any-location variables for {len(any_location_task_indices)} tasks")

    # 4.6.1 Capability & location consistency + availability
    for p_idx, person in enumerate(persons):
        for t_idx, task in enumerate(tasks):
            task_segment = task_segment_idx[t_idx]
            if task_segment is None:
                continue

            # For tasks with movement (from_location -> to_location), person must be at from_location
            if task.from_location_id is not None and task.to_location_id is not None:
                # Moving task: must start at from_location
                from_loc_idx = location_to_idx[task.from_location_id]
                task_loc_idx = from_loc_idx
                for c_idx, cap_name in enumerate(capabilities):
                    has_cap = 1 if cap_name in person.capabilities else 0
                    model.Add(x[p_idx, t_idx, c_idx] <= has_cap)
                    model.Add(x[p_idx, t_idx, c_idx] <= z[p_idx, task_segment, task_loc_idx])
                    if not person_available_for_task(p_idx, t_idx):
                        model.Add(x[p_idx, t_idx, c_idx] == 0)
            elif task.location_id is not None:
                # Regular task with fixed location
                task_loc_idx = location_to_idx[task.location_id]
                for c_idx, cap_name in enumerate(capabilities):
                    has_cap = 1 if cap_name in person.capabilities else 0
                    model.Add(x[p_idx, t_idx, c_idx] <= has_cap)
                    model.Add(x[p_idx, t_idx, c_idx] <= z[p_idx, task_segment, task_loc_idx])
                    if not person_available_for_task(p_idx, t_idx):
                        model.Add(x[p_idx, t_idx, c_idx] == 0)
            else:
                # Any-location task: person must be at whichever location the solver picks
                for c_idx, cap_name in enumerate(capabilities):
                    has_cap = 1 if cap_name in person.capabilities else 0
                    model.Add(x[p_idx, t_idx, c_idx] <= has_cap)
                    if not person_available_for_task(p_idx, t_idx):
                        model.Add(x[p_idx, t_idx, c_idx] == 0)
                    else:
                        for l in range(num_locations):
                            # x[p,t,c] AND task_loc_choice[t,l] => z[p,seg,l]
                            model.Add(
                                x[p_idx, t_idx, c_idx] + task_loc_choice[t_idx, l] - 1
                                <= z[p_idx, task_segment, l]
                            )
    print("[OK] 4.6.1 Capability & location consistency (with any-location support)")

    # 4.6.1b Task movement constraints (similar to 4.4 for transfers)
    for p in range(num_persons):
        for t_idx, task in enumerate(tasks):
            # Only apply to tasks with movement
            if task.from_location_id is not None and task.to_location_id is not None:
                task_segment = task_segment_idx[t_idx]
                if task_segment is None:
                    continue

                from_loc_idx = location_to_idx[task.from_location_id]
                to_loc_idx = location_to_idx[task.to_location_id]

                # If working on this moving task (assigned or capability), must be at from_location when task starts
                model.Add(working[p, t_idx] <= z[p, task_segment, from_loc_idx])

                # Find the segment when task ends
                end_segment = None
                for s_idx, seg in enumerate(segments):
                    if seg.start_time == task.end_time:
                        end_segment = s_idx
                        break

                # If working on this task, must be at to_location when task ends
                if end_segment is not None:
                    model.Add(z[p, end_segment, to_loc_idx] >= working[p, t_idx])
    print("[OK] 4.6.1b Task movement constraints")

    # 4.6.2 Required capability counts and task_fully_covered linkage
    # Build required_count lookup for easier access
    required_count = {}
    for t_idx, task in enumerate(tasks):
        required_count[t_idx] = {}
        for cap_name, req in task.requirements.items():
            if cap_name in capability_to_idx:
                c_idx = capability_to_idx[cap_name]
                required_count[t_idx][c_idx] = req

    # Link task_fully_covered to capability coverage
    for t in range(num_tasks):
        for c in range(num_capabilities):
            req = required_count.get(t, {}).get(c, 0)
            if req > 0:
                # If task_fully_covered[t] == 1, then sum(x[p][t][c]) >= req
                # Equivalent to: sum(x[p][t][c]) >= req * task_fully_covered[t]
                model.Add(
                    sum(x[p, t, c] for p in range(num_persons)) >= req * task_fully_covered[t]
                )

    # Handle tasks with no capability requirements - they are always fully covered
    # (except floating task candidates, which are handled by float_choice constraints)
    for t in range(num_tasks):
        if all(required_count.get(t, {}).get(c, 0) == 0 for c in range(num_capabilities)):
            # Skip floating task candidates - their coverage is linked to choice variables
            if t not in task_to_floating:
                model.Add(task_fully_covered[t] == 1)

    # Transfer capability requirements (hard constraints - transfers must be fully staffed)
    for k_idx, transfer in enumerate(transfers):
        for cap_name, required_count_val in transfer.requirements.items():
            if cap_name in capability_to_idx and required_count_val > 0:
                # Count persons with this capability who use the transfer
                persons_with_cap = [p for p in range(num_persons) if cap_name in persons[p].capabilities]
                model.Add(sum(y[p, k_idx] for p in persons_with_cap) >= required_count_val)


    print("[OK] 4.6.2 Required capability counts")

    # 4.6.3 Floating task constraints
    # Each floating task must be scheduled in exactly one candidate slot
    for ft_idx, cand_task_indices in floating_candidates.items():
        if cand_task_indices:
            model.Add(
                sum(float_choice[ft_idx, t_idx] for t_idx in cand_task_indices) == 1
            )

    # Link floating candidates to choice variables
    for ft_idx, cand_task_indices in floating_candidates.items():
        for t_idx in cand_task_indices:
            choice_var = float_choice[ft_idx, t_idx]

            # If not chosen, no one can be assigned to this candidate
            for p in range(num_persons):
                model.Add(assigned[p, t_idx] <= choice_var)
                # If not chosen, capability assignments must be 0
                for c in range(num_capabilities):
                    model.Add(x[p, t_idx, c] <= choice_var)

            # Link task_fully_covered to float_choice for candidates
            # If chosen, must be fully covered; if not chosen, cannot be covered
            model.Add(task_fully_covered[t_idx] == choice_var)

    print("[OK] 4.6.3 Floating task choice constraints")

    # 4.7 Assigned person constraints
    # 4.7.1 Exact direct assignment constraints
    # Direct person selections are hard constraints. Only selected people may be
    # assigned through the direct assignment variable.
    for t_idx, task in enumerate(tasks):
        if t_idx in task_to_floating:
            if not task.preassigned_person_ids:
                model.Add(sum(assigned[p, t_idx] for p in range(num_persons)) == 0)
            continue

        valid_preassigned = {
            person_to_idx[person_id]
            for person_id in task.preassigned_person_ids
            if person_id in person_to_idx
        }

        if not task.preassigned_person_ids:
            model.Add(sum(assigned[p, t_idx] for p in range(num_persons)) == 0)
        else:
            print(f"  Task {task.id} ({task.name}): Direct assignments locked to: {task.preassigned_person_ids}")
            for p in range(num_persons):
                model.Add(assigned[p, t_idx] == (1 if p in valid_preassigned else 0))
    print("[OK] 4.7.1 Exact direct assignments")

    # 4.7.2 Preassigned tasks
    # For static tasks: preassigned person MUST be assigned
    # For floating tasks: preassigned person must be assigned to EXACTLY ONE of the candidates (the chosen one)

    # Group preassigned constraints by floating task
    floating_preassigned: Dict[int, List[int]] = {}  # ft_idx -> list of preassigned person indices

    for t_idx, task in enumerate(tasks):
        if task.preassigned_person_ids:
            # Check if this task is a floating candidate
            if t_idx in task_to_floating:
                # This is a floating candidate - handle specially
                ft_idx = task_to_floating[t_idx]
                if ft_idx not in floating_preassigned:
                    # Store preassigned persons for this floating task (only need to do once)
                    floating_preassigned[ft_idx] = []
                    for person_id in task.preassigned_person_ids:
                        if person_id in person_to_idx:
                            floating_preassigned[ft_idx].append(person_to_idx[person_id])
            else:
                # This is a static task - apply constraint directly
                for person_id in task.preassigned_person_ids:
                    if person_id in person_to_idx:
                        p_fixed = person_to_idx[person_id]
                        model.Add(assigned[p_fixed, t_idx] == 1)

    # For floating tasks with preassigned persons:
    # The preassigned person must be assigned to the chosen candidate
    for ft_idx, preassigned_persons in floating_preassigned.items():
        cand_task_indices = floating_candidates[ft_idx]
        original_task = tasks[cand_task_indices[0]]
        print(
            f"  Floating task {original_task.id} ({original_task.name}): "
            f"Direct assignments locked to: {original_task.preassigned_person_ids}"
        )
        for p_fixed in preassigned_persons:
            # For each candidate: if chosen, preassigned person must be assigned to it
            for t_idx in cand_task_indices:
                model.Add(assigned[p_fixed, t_idx] == float_choice[ft_idx, t_idx])

        for t_idx in cand_task_indices:
            for p in range(num_persons):
                if p not in preassigned_persons:
                    model.Add(assigned[p, t_idx] == 0)

    print("[OK] 4.7.2 Preassigned tasks")

    # 4.7.3 Assigned person must be at task location/time and available
    for p in range(num_persons):
        for t_idx, task in enumerate(tasks):
            task_segment = task_segment_idx[t_idx]
            if task_segment is None:
                continue

            # For moving tasks, assigned person must start at from_location
            if task.from_location_id is not None and task.to_location_id is not None:
                task_loc_idx = location_to_idx[task.from_location_id]
                model.Add(assigned[p, t_idx] <= z[p, task_segment, task_loc_idx])
            elif task.location_id is not None:
                task_loc_idx = location_to_idx[task.location_id]
                model.Add(assigned[p, t_idx] <= z[p, task_segment, task_loc_idx])
            else:
                # Any-location task: assigned person must be at whichever location the solver picks
                if not person_available_for_task(p, t_idx):
                    model.Add(assigned[p, t_idx] == 0)
                else:
                    for l in range(num_locations):
                        model.Add(
                            assigned[p, t_idx] + task_loc_choice[t_idx, l] - 1
                            <= z[p, task_segment, l]
                        )
            # Cannot be assigned if unavailable in this segment
            if not person_available_for_task(p, t_idx):
                model.Add(assigned[p, t_idx] == 0)
    print("[OK] 4.7.3 Assigned person at location (with any-location support)")

    # 4.7.4 Assigned person is separate from capability slots
    for t_idx, task in enumerate(tasks):
        if task.preassigned_person_ids:
            for person_id in task.preassigned_person_ids:
                if person_id in person_to_idx:
                    p_fixed = person_to_idx[person_id]
                    for c in range(num_capabilities):
                        model.Add(x[p_fixed, t_idx, c] == 0)
    print("[OK] 4.7.4 Assigned person separate from capability slots")

    # 4.8 No double-booking in same segment
    for p in range(num_persons):
        for s_idx, segment in enumerate(segments):
            vars_in_segment = []

            # Add assigned variables for tasks in this segment
            for t_idx in segment.task_indices:
                vars_in_segment.append(assigned[p, t_idx])

            # Add capability variables for tasks in this segment
            for t_idx in segment.task_indices:
                for c in range(num_capabilities):
                    vars_in_segment.append(x[p, t_idx, c])

            # Add transfer variables for transfers active in this segment
            for k_idx in segment.transfer_indices:
                vars_in_segment.append(y[p, k_idx])

            if vars_in_segment:
                model.Add(sum(vars_in_segment) <= 1)
    print("[OK] 4.8 No double-booking")

    # Objective: Maximize the number of fully covered tasks
    model.Maximize(sum(task_fully_covered[t] for t in range(num_tasks)))

    # Solve
    print("\n--- SOLVING ---")
    solver = cp_model.CpSolver()
    solver.parameters.max_time_in_seconds = max_time_seconds
    status = solver.Solve(model)

    print(f"Status: {solver.StatusName(status)}")
    print(f"Solve time: {solver.WallTime():.2f}s")

    if status == cp_model.INFEASIBLE:
        print("\n[WARNING] INFEASIBLE - No solution found")
        print("\n--- INFEASIBILITY ANALYSIS ---")

        for t_idx, task in enumerate(tasks):
            task_segment_idx = None
            for s_idx, seg in enumerate(segments):
                if t_idx in seg.task_indices:
                    task_segment_idx = s_idx
                    break

            if task_segment_idx is None:
                errors.append(f"Task '{task.name}' (ID: {task.id}): No time segment found  -  check task times")
                continue

            segment = segments[task_segment_idx]
            time_str = f"{minutes_to_time_str(segment.start_time)}-{minutes_to_time_str(segment.end_time)}"

            # --- Capability checks ---
            for cap_name, required_count in task.requirements.items():
                if cap_name not in capability_to_idx:
                    errors.append(
                        f"Task '{task.name}' (ID: {task.id}) at {time_str}: "
                        f"Requires capability '{cap_name}' which doesn't exist"
                    )
                    continue

                persons_with_cap = [p for p in persons if cap_name in p.capabilities]
                total_with_cap = len(persons_with_cap)

                if total_with_cap < required_count:
                    errors.append(
                        f"Task '{task.name}' (ID: {task.id}) at {time_str}: "
                        f"Not enough '{cap_name}'  -  needs {required_count}, only {total_with_cap} exist"
                    )
                    continue

                # Check availability during this segment
                available_with_cap = [
                    p for p in persons_with_cap
                    if person_available_for_task(person_to_idx[p.id], t_idx)
                ]

                if len(available_with_cap) < required_count:
                    unavailable_ids = [
                        p.id for p in persons_with_cap
                        if not person_available_for_task(person_to_idx[p.id], t_idx)
                    ]
                    errors.append(
                        f"Task '{task.name}' (ID: {task.id}) at {time_str}: "
                        f"Not enough available '{cap_name}'  -  needs {required_count}, "
                        f"only {len(available_with_cap)} of {total_with_cap} available. "
                        f"Unavailable: persons {unavailable_ids}"
                    )
                    continue

                # Reachability: can enough people physically get to the location?
                if task.location_id is not None:
                    can_reach = []
                    cannot_reach = []
                    for p in available_with_cap:
                        if _can_person_reach(p, task.location_id, task_segment_idx, segments, transfers):
                            can_reach.append(p.id)
                        else:
                            cannot_reach.append(p.id)

                    if len(can_reach) < required_count:
                        errors.append(
                            f"Task '{task.name}' (ID: {task.id}) at {time_str} in Location {task.location_id}: "
                            f"Not enough '{cap_name}' can reach this location  -  needs {required_count}, "
                            f"only {len(can_reach)} reachable. "
                            f"Cannot reach: persons {cannot_reach}"
                        )

            # --- Preassigned person checks ---
            if task.preassigned_person_ids:
                for pid in task.preassigned_person_ids:
                    if pid not in person_to_idx:
                        errors.append(
                            f"Task '{task.name}' (ID: {task.id}) at {time_str}: "
                            f"Preassigned person {pid} not found"
                        )
                        continue

                    p_idx = person_to_idx[pid]
                    person = persons[p_idx]

                    if not person_available_for_task(p_idx, t_idx):
                        errors.append(
                            f"Task '{task.name}' (ID: {task.id}) at {time_str}: "
                            f"Preassigned person {pid} is unavailable at this time"
                        )
                    elif task.location_id is not None and not _can_person_reach(
                        person, task.location_id, task_segment_idx, segments, transfers
                    ):
                        errors.append(
                            f"Task '{task.name}' (ID: {task.id}) at {time_str} in Location {task.location_id}: "
                            f"Preassigned person {pid} cannot reach this location"
                        )

        # --- Concurrent demand: do overlapping tasks in a segment exceed capacity? ---
        for s_idx, segment in enumerate(segments):
            if len(segment.task_indices) <= 1:
                continue
            time_str = f"{minutes_to_time_str(segment.start_time)}-{minutes_to_time_str(segment.end_time)}"
            total_demand: dict[str, int] = {}
            task_names = []
            for t_idx in segment.task_indices:
                t = tasks[t_idx]
                task_names.append(f"'{t.name}' (ID: {t.id})")
                for cap, cnt in t.requirements.items():
                    total_demand[cap] = total_demand.get(cap, 0) + cnt

            for cap_name, demand in total_demand.items():
                supply = sum(
                    1 for p in persons
                    if cap_name in p.capabilities and availability[person_to_idx[p.id]][s_idx]
                )
                if demand > supply:
                    errors.append(
                        f"Time {time_str}: Overlapping tasks need {demand} '{cap_name}' "
                        f"but only {supply} available. Tasks: {', '.join(task_names)}"
                    )

        if not errors:
            fallback = core_fallback_issue(normalized_input)
            errors.append(fallback.message)

    elif status == cp_model.OPTIMAL or status == cp_model.FEASIBLE:
        print("\n[OK] FEASIBLE - Valid assignment exists")

        # Print solution summary for debug
        fully_covered_count = sum(1 for t in range(num_tasks) if solver.Value(task_fully_covered[t]) == 1)
        print(f"\nFully covered tasks: {fully_covered_count}/{num_tasks}")

        # Check uncovered tasks and diagnose why
        for t_idx, task in enumerate(tasks):
            if solver.Value(task_fully_covered[t_idx]) == 1:
                continue

            # Skip unchosen floating-task candidates
            ft_idx = task_to_floating.get(t_idx)
            if ft_idx is not None:
                choice_var = float_choice.get((ft_idx, t_idx))
                if choice_var is not None and solver.Value(choice_var) == 0:
                    continue

            task_segment_idx = None
            for s_idx, seg in enumerate(segments):
                if t_idx in seg.task_indices:
                    task_segment_idx = s_idx
                    break

            if task_segment_idx is None:
                continue

            segment = segments[task_segment_idx]
            time_str = f"{minutes_to_time_str(segment.start_time)}-{minutes_to_time_str(segment.end_time)}"
            task_has_errors = False

            for cap_name, req_count in task.requirements.items():
                if cap_name not in capability_to_idx:
                    continue
                c_idx = capability_to_idx[cap_name]
                assigned_count = sum(1 for p in range(num_persons) if solver.Value(x[p, t_idx, c_idx]) == 1)
                missing = req_count - assigned_count

                if missing > 0:
                    # Diagnose WHY people couldn't be assigned
                    persons_with_cap = [p for p in persons if cap_name in p.capabilities]
                    available_with_cap = [
                        p for p in persons_with_cap
                        if person_available_for_task(person_to_idx[p.id], t_idx)
                    ]

                    reason = ""
                    if len(persons_with_cap) < req_count:
                        reason = f"  -  only {len(persons_with_cap)} exist with this capability"
                    elif len(available_with_cap) < req_count:
                        reason = f"  -  only {len(available_with_cap)} of {len(persons_with_cap)} available at this time"
                    else:
                        # Enough exist and are available  -  must be location/booking conflict
                        # Check how many other tasks compete for this capability in this segment
                        competing_demand = 0
                        competing_names = []
                        for other_t_idx in segment.task_indices:
                            if other_t_idx == t_idx:
                                continue
                            other_task = tasks[other_t_idx]
                            other_req = other_task.requirements.get(cap_name, 0)
                            if other_req > 0:
                                competing_demand += other_req
                                competing_names.append(other_task.name)

                        if competing_demand > 0:
                            total_demand = req_count + competing_demand
                            reason = (
                                f"  -  {len(available_with_cap)} available but {total_demand} needed at this time "
                                f"(also needed by: {', '.join(competing_names)})"
                            )
                        elif task.location_id is not None:
                            reachable = sum(
                                1 for p in available_with_cap
                                if _can_person_reach(p, task.location_id, task_segment_idx, segments, transfers)
                            )
                            if reachable < req_count:
                                reason = f"  -  only {reachable} can reach this location in time"
                            else:
                                reason = "  -  location/scheduling conflict with other tasks"
                        else:
                            reason = "  -  scheduling conflict with other tasks"

                    loc_label = f"in Location {task.location_id}" if task.location_id is not None else ""
                    errors.append(
                        f"Task '{task.name}' (ID: {task.id}) at {time_str} {loc_label}: "
                        f"Needs {req_count} '{cap_name}', got {assigned_count}{reason}"
                    )
                    task_has_errors = True

            if not task_has_errors:
                loc_label = f"in Location {task.location_id}" if task.location_id is not None else ""
                errors.append(
                    f"Task '{task.name}' (ID: {task.id}) at {time_str} {loc_label}: "
                    f"Cannot be fully covered  -  scheduling conflict"
                )

    else:
        print(f"Solver returned status: {solver.StatusName(status)}")

    print("\n" + "=" * 80)

    if status not in [cp_model.OPTIMAL, cp_model.FEASIBLE, cp_model.INFEASIBLE]:
        issue = DiagnosticIssue(
            code="SOLVER_UNDETERMINED",
            category="solver",
            message=(
                "The flow checker stopped before it could prove whether all "
                "requirements are satisfiable."
            ),
            facts=(("Solver status", solver.StatusName(status)),),
            suggestions=("Run the check again or increase the solver time limit.",),
        )
        return result([issue.message], "undetermined", [issue])
    if errors:
        issues = [legacy_message_issue(message, normalized_input) for message in errors]
        return result(errors, "infeasible", issues)
    return result([], "feasible", [])

Compute Entrypoint

main

Optimiser FastAPI Service Wraps optimisation algorithms in an HTTP API

Settings

Bases: BaseSettings

Runtime settings for the standalone compute service.

Source code in compute/src/main.py
class Settings(BaseSettings):
    """Runtime settings for the standalone compute service."""

    BACKEND_URL: str = "http://127.0.0.1:8000"
    OPTIMIZER_HOST: str = "127.0.0.1"
    OPTIMIZER_PORT: int = 8765

    class Config:
        env_file = ".env"

OptimizeDayRequest

Bases: BaseModel

Request body for solving one event day.

Source code in compute/src/main.py
class OptimizeDayRequest(BaseModel):
    """Request body for solving one event day."""

    event_id: int
    date: str
    normalized_input: Dict[str, Any]  # Contains tasks, persons, transfers, floating_tasks, errors
    request_id: Optional[str] = None  # Backend can supply a request_id for progress polling

check_desktop_token async

check_desktop_token(request: Request, call_next)

Reject requests without a valid desktop auth token.

Source code in compute/src/main.py
@app.middleware("http")
async def check_desktop_token(request: Request, call_next):
    """Reject requests without a valid desktop auth token."""
    if _DESKTOP_AUTH_TOKEN and request.url.path not in ("/health", "/"):
        token = request.headers.get("x-desktop-token")
        if token != _DESKTOP_AUTH_TOKEN:
            return JSONResponse(status_code=403, content={"detail": "Forbidden"})
    return await call_next(request)

limit_body_size async

limit_body_size(request: Request, call_next)

Reject request bodies larger than the configured limit.

Source code in compute/src/main.py
@app.middleware("http")
async def limit_body_size(request: Request, call_next):
    """Reject request bodies larger than the configured limit."""
    content_length = request.headers.get("content-length")
    if content_length and int(content_length) > _MAX_BODY_SIZE:
        return JSONResponse(status_code=413, content={"detail": "Request body too large"})
    return await call_next(request)

on_startup async

on_startup()

Log compute service startup metadata.

Source code in compute/src/main.py
@app.on_event("startup")
async def on_startup():
    """Log compute service startup metadata."""
    logger.info(f"Compute service STARTED at {_start_time.isoformat()} on {settings.OPTIMIZER_HOST}:{settings.OPTIMIZER_PORT}")
    logger.info(f"PID: {os.getpid()}")

on_shutdown async

on_shutdown()

Log compute service shutdown metadata.

Source code in compute/src/main.py
@app.on_event("shutdown")
async def on_shutdown():
    """Log compute service shutdown metadata."""
    logger.warning(f"Compute service SHUTTING DOWN at {datetime.utcnow().isoformat()} (was up since {_start_time.isoformat()})")

health async

health()

Health check endpoint with uptime info.

Source code in compute/src/main.py
@app.get("/health")
async def health():
    """Health check endpoint with uptime info."""
    uptime = (datetime.utcnow() - _start_time).total_seconds()
    return {
        "status": "healthy",
        "pid": os.getpid(),
        "started_at": _start_time.isoformat(),
        "uptime_seconds": round(uptime, 1),
    }

root async

root()

Readiness endpoint for the compute service root.

Source code in compute/src/main.py
@app.get("/")
async def root():
    """Readiness endpoint for the compute service root."""
    return {"message": "Masterplan Optimiser Service", "status": "ready"}

get_progress async

get_progress(request_id: str)

Return intermediate solver snapshots for a running optimisation.

Source code in compute/src/main.py
@app.get("/optimize/progress/{request_id}")
async def get_progress(request_id: str):
    """Return intermediate solver snapshots for a running optimisation."""
    cb = _active_callbacks.get(request_id)
    if cb is None:
        return {"snapshots": [], "is_running": False, "max_time_seconds": None}
    return {
        "snapshots": list(cb.snapshots),
        "is_running": True,
        "max_time_seconds": _active_timeouts.get(request_id),
    }

optimize_day async

optimize_day(request: OptimizeDayRequest)

Run optimisation for a specific day using the fatigue optimiser.

Source code in compute/src/main.py
@app.post("/optimize/day")
async def optimize_day(request: OptimizeDayRequest):
    """
    Run optimisation for a specific day using the fatigue optimiser.
    """
    print("\n" + "="*80)
    print("OPTIMISATION REQUEST RECEIVED")
    print("="*80)
    print(f"Event ID: {request.event_id}")
    print(f"Date: {request.date}")

    # Summary counts
    print(f"\n--- NORMALIZED INPUT SUMMARY ---")
    print(f"Tasks: {len(request.normalized_input.get('tasks', []))}")
    print(f"Persons: {len(request.normalized_input.get('persons', []))}")
    print(f"Transfers: {len(request.normalized_input.get('transfers', []))}") 
    print(f"Floating Tasks: {len(request.normalized_input.get('floating_tasks', []))}")
    print(f"Errors: {len(request.normalized_input.get('errors', []))}")

    try:
        # Import flow_checker structures to convert dict to proper objects
        from flow_checker import NormalizedFlowInput, NormPerson, NormTask, NormTransfer, NormFloatingTask

        # Convert dict to NormalizedFlowInput object
        print("\n--- CONVERTING INPUT TO NORMALIZED OBJECTS ---")

        # Convert persons
        persons = []
        for p in request.normalized_input.get('persons', []):
            # Convert unavailable_intervals to list of tuples
            unavailable = []
            for interval in p.get('unavailable_intervals', []):
                if isinstance(interval, dict):
                    unavailable.append((interval['start'], interval['end']))
                elif isinstance(interval, (list, tuple)) and len(interval) == 2:
                    unavailable.append(tuple(interval))

            person = NormPerson(
                id=p['id'],
                home_location_id=p.get('initial_location_id'),
                capabilities=p.get('capabilities', []),
                max_work_minutes_per_day=p.get('max_work_minutes_per_day'),
                unavailable_intervals=unavailable,
                initial_fatigue=float(p.get('initial_fatigue', 0.0))
            )
            person.name = p.get('name', '') or f"Person {p['id']}"
            persons.append(person)

        # Convert tasks - NormTask uses 'requirements' not 'required_capabilities'
        tasks = []
        for t in request.normalized_input.get('tasks', []):
            task = NormTask(
                id=t['id'],
                name=t['name'],
                location_id=t['location_id'],
                start_time=t['start_time'],
                end_time=t['end_time'],
                requirements=t.get('required_capabilities', {}),  # Map to 'requirements'
                preassigned_person_ids=t.get('preassigned_person_ids', []),
                field_requirements=t.get('field_requirements', {}),
                counts_towards_work_time=t.get('counts_towards_work_time', True) is not False,
            )
            # Add fatigue_per_minute as dynamic attribute (not in dataclass)
            task.fatigue_per_minute = t.get('fatigue_per_minute', 1.0)
            tasks.append(task)

        # Convert transfers
        transfers = []
        for tr in request.normalized_input.get('transfers', []):
            transfers.append(NormTransfer(
                id=tr['id'],
                from_location_id=tr['from_location_id'],
                to_location_id=tr['to_location_id'],
                depart_time=tr['depart_time'],
                arrive_time=tr['arrive_time'],
                capacity=tr['capacity'],
                requirements=tr.get('required_capabilities', {}),
                field_requirements=tr.get('field_requirements', {}),
                transferee_field_id=tr.get('transferee_field_id'),
                counts_towards_work_time=tr.get('counts_towards_work_time', True) is not False,
            ))

        # Convert floating tasks - simpler structure without candidates
        floating_tasks = []
        for ft in request.normalized_input.get('floating_tasks', []):
            # If backend sends candidates, compute window from all candidates
            if ft.get('candidates') and len(ft['candidates']) > 0:
                candidates = ft['candidates']
                first_candidate = candidates[0]

                # Window is the span of all candidates (earliest start to latest end)
                window_start = min(c['start_time'] for c in candidates)
                window_end = max(c['end_time'] for c in candidates)

                # Duration is from the first candidate (all should have same duration)
                duration = first_candidate['end_time'] - first_candidate['start_time']

                floating_task = NormFloatingTask(
                    id=ft['id'],
                    name=ft['name'],
                    location_id=first_candidate['location_id'],
                    window_start_time=window_start,
                    window_end_time=window_end,
                    duration=duration,
                    requirements=first_candidate.get('required_capabilities', {}),
                    preassigned_person_ids=first_candidate.get('preassigned_person_ids', []),
                    counts_towards_work_time=first_candidate.get('counts_towards_work_time', True) is not False,
                )
                floating_task.fatigue_per_minute = first_candidate.get('fatigue_per_minute', 1.0)
                floating_tasks.append(floating_task)

        normalized = NormalizedFlowInput(
            persons=persons,
            tasks=tasks,
            transfers=transfers,
            floating_tasks=floating_tasks,
            errors=request.normalized_input.get('errors', []),
            capability_names=request.normalized_input.get('capability_names', {}),
            location_names=request.normalized_input.get('location_names', {}),
        )

        print(f"Converted: {len(persons)} persons, {len(tasks)} tasks, {len(transfers)} transfers, {len(floating_tasks)} floating")

        # Expand floating tasks into candidates BEFORE calling optimiser
        # This replicates the logic from fatigue_optimizer.py so we have access to the expanded tasks
        from flow_checker import NormTask, generate_time_segments

        segments = generate_time_segments(tasks, transfers, floating_tasks)
        expanded_tasks = list(tasks)  # Start with static tasks

        if floating_tasks:
            print(f"\n--- PRE-EXPANDING {len(floating_tasks)} FLOATING TASKS ---")
            for ft in floating_tasks:
                for s_idx, seg in enumerate(segments):
                    seg_start = seg.start_time

                    # Check if task can fit starting at this segment
                    if seg_start < ft.window_start_time:
                        continue
                    if seg_start + ft.duration > ft.window_end_time:
                        continue

                    # Create candidate task with actual scheduled times
                    candidate_task = NormTask(
                        id=ft.id,
                        name=f"{ft.name} [floating@seg{s_idx}]",
                        location_id=ft.location_id,
                        start_time=seg_start,
                        end_time=seg_start + ft.duration,
                        requirements=dict(ft.requirements),
                        preassigned_person_ids=ft.preassigned_person_ids,
                        counts_towards_work_time=ft.counts_towards_work_time,
                    )

                    # Copy fatigue_per_minute if floating task has it
                    if hasattr(ft, 'fatigue_per_minute'):
                        candidate_task.fatigue_per_minute = ft.fatigue_per_minute

                    expanded_tasks.append(candidate_task)
                    print(f"  '{ft.name}' -> candidate at seg{s_idx}: task[{len(expanded_tasks)-1}]")

        print(f"Total expanded tasks: {len(expanded_tasks)}")

        # Call the actual fatigue optimiser
        print("\n--- CALLING FATIGUE OPTIMISER ---")

        # Build solver config from request (if provided by backend)
        solver_config = None
        raw_config = request.normalized_input.get("solver_config")
        if raw_config:
            solver_config = OptimizationConfig(
                scale=raw_config.get("scale", 100),
                break_threshold_min=raw_config.get("break_threshold_min", 30),
                break_effect=raw_config.get("break_effect", -0.5),
                max_time_seconds=raw_config.get("max_time_seconds", 30.0),
            )
            print(f"Solver config: scale={solver_config.scale}, break_threshold={solver_config.break_threshold_min}min, "
                  f"break_effect={solver_config.break_effect}, max_time={solver_config.max_time_seconds}s")

        # Use backend-supplied request_id if available, else generate one
        request_id = request.request_id or uuid.uuid4().hex
        max_time = (solver_config or OptimizationConfig()).max_time_seconds

        # Create callback externally and register so GET /optimize/progress
        # can read intermediate snapshots while the solver is running.
        cb = ProgressCallback(scale=100)  # Scale will be reset by optimize_with_fatigue
        _active_callbacks[request_id] = cb
        _active_timeouts[request_id] = max_time

        try:
            # Run in thread so event loop stays responsive for progress polls
            result = await asyncio.to_thread(
                optimize_with_fatigue,
                normalized_input=normalized,
                config=solver_config,
                callback=cb,
            )
        finally:
            _active_callbacks.pop(request_id, None)
            _active_timeouts.pop(request_id, None)

        print(f"\n--- OPTIMISATION COMPLETE ---")
        print(f"Status: {result.status}")
        print(f"Assignments dict keys: {list(result.assignments.keys())}")
        print(f"Assignments: {len(result.assignments)} tasks assigned")

        # Debug: Show assignment details
        if result.assignments:
            for task_id, person_ids in list(result.assignments.items())[:3]:
                print(f"  Task {task_id}: {len(person_ids)} persons -> {person_ids}")

        print(f"Solve time: {result.solve_time:.2f}s")

        if result.errors:
            print(f"Errors: {len(result.errors)}")
            for error in result.errors:
                print(f"  - {error}")

        print("="*80 + "\n")

        # Build task lookup dictionary using our pre-expanded tasks
        # These have the actual scheduled start/end times for floating task candidates
        task_lookup = {t.id: t for t in expanded_tasks}

        print(f"\nBuilding response:")
        print(f"  Task lookup has {len(task_lookup)} tasks (pre-expanded)")
        print(f"  Task IDs in lookup: {list(task_lookup.keys())}")
        print(f"  Assignment task IDs (preassigned): {list(result.assignments.keys())}")
        print(f"  Capability assignment task IDs: {[task_id for (task_id, cap_name) in result.capability_assignments.keys()]}")

        # Track which persons are working on which tasks (either preassigned or capability provider)
        task_person_map = {}  # task_id -> set of person_ids

        # Add preassigned persons
        for task_id, person_ids in result.assignments.items():
            if task_id not in task_person_map:
                task_person_map[task_id] = set()
            task_person_map[task_id].update(person_ids)

        # Add capability providers
        for (task_id, cap_name), person_ids in result.capability_assignments.items():
            if task_id not in task_person_map:
                task_person_map[task_id] = set()
            task_person_map[task_id].update(person_ids)

        # Add transfer assignments
        if result.transfer_assignments:
            for transfer_id, person_ids in result.transfer_assignments.items():
                if transfer_id not in task_person_map:
                    task_person_map[transfer_id] = set()
                task_person_map[transfer_id].update(person_ids)

        # Convert to assignment list for backend
        assignments_list = []
        for task_id, person_ids in task_person_map.items():
            # Get task details from optimiser result
            if task_id not in result.task_details:
                print(f"WARNING: Task {task_id} not found in task details (available: {list(result.task_details.keys())})")
                continue

            task_detail = result.task_details[task_id]

            # Use original floating task ID for API response
            output_task_id = task_detail['original_id']

            for person_id in person_ids:
                # Use task details from optimiser (which has correct timing for selected candidates)
                assignments_list.append({
                    "person_id": person_id,
                    "task_id": output_task_id,  # Use original floating task ID for API response
                    "start_time": task_detail['start_time'],
                    "end_time": task_detail['end_time'],
                    "location_id": task_detail['location_id'],
                    "fatigue_contributed": result.fatigue_per_person.get(person_id, 0.0)
                })

        print(f"Returning {len(assignments_list)} assignments in response")
        print(f"  Assignments: {[(a['person_id'], a['task_id']) for a in assignments_list]}")

        # Build field_assignments response (task/transfer id -> {field_id -> [person_ids]})
        # Convert integer keys to strings for JSON serialization
        field_assignments_response = {}
        if result.field_assignments:
            for task_id, field_map in result.field_assignments.items():
                field_assignments_response[str(task_id)] = field_map

        return {
            "status": result.status,
            "assignments": assignments_list,
            "field_assignments": field_assignments_response,
            "fatigue_stats": {
                "min": result.fatigue_min,
                "max": result.fatigue_max,
                "range": result.fatigue_range,
                "per_person": result.fatigue_per_person
            },
            "solve_time": result.solve_time,
            "errors": result.errors,
            "request_id": request_id,
            "progress_snapshots": result.progress_snapshots or [],
            "diagnostics": result.diagnostics,
        }

    except Exception as e:
        print(f"\n--- OPTIMISATION FAILED ---")
        print(f"ERROR: {str(e)}")
        import traceback
        traceback.print_exc()
        print("="*80 + "\n")

        raise HTTPException(
            status_code=500,
            detail=f"Optimisation failed: {str(e)}"
        )