Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/v01/entities/vrp_input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ module VrpMissions
use :vrp_request_timewindow
end
optional(:lapse, type: Integer, values: ->(v) { v.positive? }, desc: 'Repeating rest after this many seconds of work (travel + service). Exclusive with timewindows. Not available with periodic heuristic.', coerce_with: ->(value) { ScheduleType.type_cast(value) })
optional(:initial_offset, type: Integer, values: ->(v) { !v.negative? }, default: 0, desc: 'Work already performed before the tour starts (seconds of travel + service + setup). Shifts the first regulatory (lapse) pause earlier. Only used with lapse.', coerce_with: ->(value) { ScheduleType.type_cast(value) })
optional(:late_multiplier, type: Float, desc: 'Late multiplier applied for this rest')
optional(:exclusion_cost, type: Float, desc: 'Cost induced by non affectation of this rest')
mutually_exclusive :timewindows, :lapse
Expand Down
2 changes: 2 additions & 0 deletions docs/Rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ A rest with `timewindows` is placed by the solver in that slot.
A rest with `lapse` is a repeating pause of `duration` seconds every `lapse` seconds of work (travel + service).
In the current implementation, travel and service times are inflated by `duration / lapse` for the solver (rounded up). Customer timewindow **starts** are delayed by the same rate from tour start, capped at `duration` (one pause); **ends** and vehicle amplitude stay unchanged. Discrete pauses are then reinserted: after the visit if the lapse is reached during service, before the next drive if travel would complete it (unless that misses the next stop's timewindow). Solvers never receive these rests.

`rests[].initial_offset` (seconds, default 0) counts work already done before the tour starts toward the first lapse, so the first pause is due sooner. Only used with `lapse`.

```json
{
"rests": [{
Expand Down
178 changes: 178 additions & 0 deletions docs/planner-web-regulatory-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Specs planner-web — pauses réglementaires

Document à transmettre tel quel à **planner-web**.

Côté optimizer-api, la feature est déjà implémentée (`rests[].lapse`, interpreter `RegulatoryRest`). Pas de nouvel endpoint.

Deux modes de pause coexistent :

- **Pause classique** : `duration` + `timewindows` (créneau horaire, 1 pause). Comportement historique.
- **Pause réglementaire** : `duration` + `lapse` (toutes les X heures de **travail**, répétable). Nouveau.

Ils sont **mutuellement exclusifs** (`timewindows` XOR `lapse`).

```mermaid
flowchart LR
subgraph ui [planner-web]
Form[Formulaire véhicule / pause]
Timeline[Timeline tournée]
end
subgraph api [optimizer-api]
VRP["VRP rests + rest_ids"]
Interp[RegulatoryRest]
Sol["Solution activities type=rest"]
end
Form -->|POST VRP| VRP
VRP --> Interp
Interp --> Sol
Sol --> Timeline
```

---

## 1. Contrat API (input)

### 1.1 Champ nouveau

Sur chaque objet de `vrp.rests[]` :

| Champ | Type | Unité | Obligatoire | Contraintes |
|---|---|---|---|---|
| `id` | string | — | oui | identifiant unique du rest |
| `duration` | integer ou `"H:MM:SS"` | secondes | oui si `lapse` | `> 0` et **strictement inférieur** à `lapse` |
| `lapse` | integer ou `"H:MM:SS"` | secondes | non | si présent : `> 0` ; **interdit** avec `timewindows` |
| `initial_offset` | integer ou `"H:MM:SS"` | secondes | non | défaut `0` ; travail déjà fait avant la tournée (avec `lapse` seulement) |
| `timewindows` | array (max 1) | — | non | mode classique uniquement |

Les durées acceptent les formats optimizer : `2700`, `"0:45:00"`, `"0:45"`.

### 1.2 Rattachement véhicule

Inchangé : `vehicles[].rest_ids: ["pause_reglementaire"]`.

Sans `rest_ids`, le rest est ignoré (pas d’erreur). **Il faut l’assigner à chaque véhicule** concerné.

### 1.3 Exemple request (45 min toutes les 6 h de travail)

```json
{
"vrp": {
"rests": [{
"id": "pause_reglementaire",
"duration": 2700,
"lapse": 21600
}],
"vehicles": [{
"id": "vehicle_0",
"rest_ids": ["pause_reglementaire"],
"timewindow": { "start": 28800, "end": 64800 }
}]
}
}
```

Équivalent lisible : `duration: "0:45:00"`, `lapse: "6:00:00"`.

### 1.4 Sémantique (à afficher / documenter dans l’UI)

- `lapse` = durée de **travail** (trajet + service + setup), **pas** l’attente chez le client.
- `rests[].initial_offset` (secondes, défaut 0) = travail déjà effectué avant le début de la tournée ; compte pour la **première** pause (celle-ci arrive plus tôt). Uniquement avec `lapse`.
- Au bout de `lapse` secondes de travail → pause de `duration`, **répétable** (2e pause après 2×`lapse`, etc.).
- Formule solver : chaque heure de mission/trajet est gonflée de `duration / lapse` (durées au supérieur). Les **ouvertures** de créneaux clients sont reculées du même ratio depuis le début de tournée, **plafonnées à `duration`** (une pause). Les **fermetures** et l’amplitude véhicule restent inchangées.
- Plusieurs pauses par tournée sont voulues et supportées.
- Les rests `lapse` ne sont **pas** envoyés aux solveurs et **ne skippent** aucun solveur (`assert_no_rest` ignore ces rests).
- **Indispo** : optimisation périodique / schedule (`first_solution_strategy` periodic, plage `configuration.schedule`).

### 1.5 Erreurs à gérer côté UI

| Condition | Erreur API |
|---|---|
| `lapse` et `timewindows` ensemble | validation Grape `mutually_exclusive` |
| `lapse` sans `duration` > 0 | `DiscordantProblemError`: *Rests with lapse require a strictly positive duration* |
| `duration >= lapse` | `DiscordantProblemError`: *Rest duration must be smaller than lapse* |
| plusieurs rests `lapse` avec des ratios `duration/lapse` différents | `UnsupportedProblemError`: *Regulatory rests must share the same duration/lapse ratio* |
| pause réglementaire + planning périodique | `UnsupportedProblemError`: *Rests are not available with periodic heuristic* |

Recommandation UI : un seul couple `(durée pause, lapse)` **par optimisation** (tous les véhicules partagent le même ratio). Varier uniquement l’activation par véhicule (`rest_ids`), pas les durées.

---

## 2. Contrat API (output)

Les pauses réinsérées sont des **activités de tournée** existantes :

- `type`: `"rest"` (libellé FR export : `pause`)
- `rest_id`: id d’origine du rest (ex. `"pause_reglementaire"`)
- `detail.duration`: durée de la pause (secondes)
- `begin_time` / `end_time` / `departure_time`: horodatage de la pause
- `travel_time` / `waiting_time`: 0 sur l’arrêt pause

**Point d’attention UI** : plusieurs pauses d’une même règle partagent le **même** `rest_id` (et souvent le même `id`). Ne pas keyer les stops d’une tournée par `rest_id`. Afficher **chaque** activity `type=rest` dans l’ordre de `activities[]`.

Exemple de séquence :

```text
depot → service → rest → service → rest → service → rest → depot
```

Les totaux de tournée (`total_time`, `end_time`, …) **incluent** les pauses.

---

## 3. Ticket UI planner-web

### 3.1 User story

En tant que planificateur, je configure une pause réglementaire (durée Y toutes les X heures de travail) sur les véhicules, je lance l’optimisation, et je vois les pauses réellement insérées dans la timeline / la carte / l’export.

### 3.2 Saisie (véhicule ou réglage flotte)

Étendre l’écran pause existant avec un **type** :

1. **Créneau horaire** (classique) — champs actuels : durée + fenêtre.
2. **Pause réglementaire** (nouveau) — champs :
- Durée de pause (time picker, défaut proposé : `0:45`)
- Toutes les X heures de travail (time picker, défaut proposé : `6:00`)
- Helper visible : « 45 min toutes les 6 h → environ 7 min 30 s de pause réservées par heure de tournée »

Règles UX :

- Masquer les timewindows si type réglementaire (et inversement).
- Valider `durée < lapse` avant envoi.
- Si plusieurs véhicules : même `(durée, lapse)` ; checkbox « appliquer à tous les véhicules du zonage / de la flotte ».
- Désactiver ou warning si le job est un planning périodique / multi-jours schedule.
- Libellés FR : utiliser l’espace insécable avant `:` (`Durée de pause :`, `Toutes les :`).

Payload à produire : un `rests[]` + `rest_ids` comme en §1.3. Pas de nouveau endpoint.

### 3.3 Restitution

- Timeline / Gantt : barre `pause` de `begin_time` → `end_time`, même style que les rests classiques.
- Liste d’arrêts : type `pause`, pas de client / pas d’adresse obligatoire (pas de `point_id`).
- Carte : pas de géométrie propre (l’arc reste entre les deux visites). Placement : voir §3.4.
- Plusieurs pauses possibles sur **une** tournée.
- Export CSV / PDF : déjà `type_rest: pause` ; une ligne par occurrence.

### 3.4 Hors scope / limites à assumer dans l’UI (ne pas « corriger » côté front)

- La pause est un saut discret alors que le solver a vu des durées gonflées en continu : un créneau juste après la pause peut encore être raté.
- Durées envoyées au solver : arrondi au **supérieur**. Ouvertures reculées (max = durée de pause) ; fermetures et amplitude véhicule inchangées.
- L’attente chez le client **ne compte pas** dans les X heures.
- Placement : **après** la visite si le `lapse` est atteint pendant le service ; **avant** le trajet si c’est le déplacement qui ferait l’atteindre, sauf créneau suivant trop serré. Jamais au milieu d’une visite.

### 3.5 Acceptance criteria

- [ ] Je peux créer une pause réglementaire 45 min / 6 h et la lier à un ou plusieurs véhicules.
- [ ] Je ne peux pas combiner créneau horaire et `lapse` sur la même pause.
- [ ] Le VRP envoyé à optimizer-api contient `rests[].lapse` + `vehicles[].rest_ids`.
- [ ] Après solve, la tournée affiche 0, 1 ou N pauses `type=rest` selon la durée de travail réelle.
- [ ] Deux pauses de la même règle s’affichent toutes les deux (pas d’écrasement par `rest_id`).
- [ ] Planning périodique : le champ est masqué ou une erreur claire est affichée.
- [ ] Tests UI happy path : tournée assez longue pour 2 pauses, les deux apparaissent dans la timeline.

### 3.6 Copy suggéré (FR)

- Titre : `Pause réglementaire`
- Champ (payload `lapse`) : `Toutes les`
- Suffixe : `heures de travail`
- Aide : `Le temps d’attente chez le client n’est pas compté. Plusieurs pauses peuvent être insérées sur une même tournée.`
3 changes: 2 additions & 1 deletion lib/interpreters/regulatory_rest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ def rebuild_route_with_pauses!(vrp, route, rest)
vehicle = route.vehicle
lapse = rest.lapse
pause_duration = rest.duration
work = 0
# Prior duty time before this tour (e.g. work already done that morning).
work = rest.initial_offset.to_i
pause_index = 0
previous_index = nil
previous_point_id = nil
Expand Down
2 changes: 2 additions & 0 deletions models/rest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class Rest < Activity
field :original_id
field :duration, default: 0
field :lapse, default: nil
# Work already done before the tour starts (seconds). Shifts the first lapse pause earlier.
field :initial_offset, default: 0
field :late_multiplier, default: 0, vrp_result: :hide
field :exclusion_cost, default: nil, vrp_result: :hide

Expand Down
13 changes: 13 additions & 0 deletions test/lib/interpreters/regulatory_rest_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,19 @@ def test_patch_inserts_repeatable_pauses_from_accumulated_work
assert_equal [:depot, :service, :rest, :service, :rest, :service, :rest, :depot], stops.map(&:type)
end

def test_patch_counts_initial_offset_toward_first_lapse
problem = pause_problem
problem[:matrices].first[:time] = uniform_time_matrix(10)
problem[:services].each{ |service| service[:activity][:duration] = 40 }
# Without offset: travel 10 + service 40 = 50 per stop → first pause after S2 (100s).
# With 90s already worked: first travel completes the lapse → pause before S1.
problem[:rests].first[:initial_offset] = 90

_vrp, stops = patched_stops(problem)

assert_equal [:depot, :rest, :service, :service, :rest, :service, :depot], stops.map(&:type)
end

def test_patch_inserts_pause_after_service_when_lapse_exceeded_during_service
problem = pause_problem
problem[:matrices].first[:time] = uniform_time_matrix(10)
Expand Down
1 change: 1 addition & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ def self.coerce(vrp)
vrp[:rests]&.each{ |rest|
rest[:duration] = ScheduleType.type_cast(rest[:duration] || 0)
rest[:lapse] = ScheduleType.type_cast(rest[:lapse]) if rest[:lapse]
rest[:initial_offset] = ScheduleType.type_cast(rest[:initial_offset]) if rest[:initial_offset]
}

vrp[:vehicles].each{ |vehicle|
Expand Down
Loading