@@ -28,20 +28,51 @@ fn create_fleet(days: usize) -> Fleet {
2828 . build ( )
2929}
3030
31+ /// A job here stands for work the solver cannot move off its shift: one pinned by a relation, or a
32+ /// service job the vehicle generates for that shift alone, such as a break.
33+ const PINNED_LOCATION : Location = 100 ;
34+
3135fn create_objective ( fleet : & Fleet ) -> Arc < dyn FeatureObjective > {
32- create_prefer_early_tours_feature ( "prefer_early_tours" , get_earliest_shift_start ( fleet) ) . unwrap ( ) . objective . unwrap ( )
36+ create_objective_with ( fleet, Arc :: new ( |_| true ) )
37+ }
38+
39+ /// Builds an objective which treats jobs at [`PINNED_LOCATION`] as unmovable.
40+ fn create_pinning_objective ( fleet : & Fleet ) -> Arc < dyn FeatureObjective > {
41+ create_objective_with ( fleet, Arc :: new ( |job : & Job | job_location ( job) != Some ( PINNED_LOCATION ) ) )
42+ }
43+
44+ fn create_objective_with ( fleet : & Fleet , is_discretionary_fn : DiscretionaryJobFn ) -> Arc < dyn FeatureObjective > {
45+ create_prefer_early_tours_feature ( "prefer_early_tours" , get_earliest_shift_start ( fleet) , is_discretionary_fn)
46+ . unwrap ( )
47+ . objective
48+ . unwrap ( )
49+ }
50+
51+ fn job_location ( job : & Job ) -> Option < Location > {
52+ job. as_single ( ) . and_then ( |single| single. places . first ( ) ) . and_then ( |place| place. location )
3353}
3454
3555/// Creates a route on the given day's shift with `job_count` jobs in its tour.
3656fn create_route_ctx ( fleet : & Fleet , day : usize , job_count : usize ) -> RouteContext {
57+ create_route_ctx_at ( fleet, day, ( 0 ..job_count) . map ( |idx| idx + 1 ) . collect ( ) )
58+ }
59+
60+ /// Creates a route on the given day's shift with a job at each of the given locations.
61+ fn create_route_ctx_at ( fleet : & Fleet , day : usize , locations : Vec < Location > ) -> RouteContext {
3762 let route = RouteBuilder :: default ( )
3863 . with_vehicle ( fleet, format ! ( "day_{day}" ) . as_str ( ) )
39- . add_activities ( ( 0 ..job_count ) . map ( |idx | ActivityBuilder :: with_location ( idx + 1 ) . build ( ) ) )
64+ . add_activities ( locations . into_iter ( ) . map ( |location | ActivityBuilder :: with_location ( location ) . build ( ) ) )
4065 . build ( ) ;
4166
4267 RouteContextBuilder :: default ( ) . with_route ( route) . build ( )
4368}
4469
70+ fn get_fitness ( objective : & Arc < dyn FeatureObjective > , fleet : & Fleet , days : Vec < ( usize , Vec < Location > ) > ) -> Cost {
71+ let routes = days. into_iter ( ) . map ( |( day, locations) | create_route_ctx_at ( fleet, day, locations) ) . collect ( ) ;
72+
73+ objective. fitness ( & TestInsertionContextBuilder :: default ( ) . with_routes ( routes) . build ( ) )
74+ }
75+
4576parameterized_test ! { can_estimate_delay_of_opening_a_shift, ( day, expected) , {
4677 can_estimate_delay_of_opening_a_shift_impl( day, expected) ;
4778} }
@@ -150,3 +181,93 @@ fn can_use_first_job_arrival_floor_when_shift_start_is_relaxed() {
150181 assert_eq ! ( actor. detail. time. start, 0. ) ;
151182 assert_eq ! ( get_shift_start( actor. as_ref( ) ) , 3. * DAY ) ;
152183}
184+
185+ parameterized_test ! { can_estimate_delay_for_a_shift_holding_only_unmovable_work, ( day, expected) , {
186+ can_estimate_delay_for_a_shift_holding_only_unmovable_work_impl( day, expected) ;
187+ } }
188+
189+ can_estimate_delay_for_a_shift_holding_only_unmovable_work ! {
190+ case_01_earliest_shift_is_still_free: ( 0 , 0. ) ,
191+ case_02_last_day: ( 4 , 4. * DAY ) ,
192+ }
193+
194+ fn can_estimate_delay_for_a_shift_holding_only_unmovable_work_impl ( day : usize , expected : Cost ) {
195+ // a standing appointment does not pay for the shift, so the first job which could have gone
196+ // elsewhere still does - otherwise that day reads as already paid for and draws in new work
197+ let fleet = create_fleet ( 5 ) ;
198+ let objective = create_pinning_objective ( & fleet) ;
199+ let route_ctx = create_route_ctx_at ( & fleet, day, vec ! [ PINNED_LOCATION ] ) ;
200+ let solution_ctx = TestInsertionContextBuilder :: default ( ) . build ( ) . solution ;
201+ let job = TestSingleBuilder :: default ( ) . location ( Some ( 1 ) ) . build_as_job_ref ( ) ;
202+
203+ let result = objective. estimate ( & MoveContext :: route ( & solution_ctx, & route_ctx, & job) ) ;
204+
205+ assert_eq ! ( result, expected) ;
206+ }
207+
208+ #[ test]
209+ fn can_estimate_nothing_for_a_shift_already_holding_movable_work ( ) {
210+ // once some movable work is on the shift, the delay is already paid and the rest is free
211+ let fleet = create_fleet ( 5 ) ;
212+ let objective = create_pinning_objective ( & fleet) ;
213+ let route_ctx = create_route_ctx_at ( & fleet, 4 , vec ! [ PINNED_LOCATION , 1 ] ) ;
214+ let solution_ctx = TestInsertionContextBuilder :: default ( ) . build ( ) . solution ;
215+ let job = TestSingleBuilder :: default ( ) . location ( Some ( 2 ) ) . build_as_job_ref ( ) ;
216+
217+ let result = objective. estimate ( & MoveContext :: route ( & solution_ctx, & route_ctx, & job) ) ;
218+
219+ assert_eq ! ( result, 0. ) ;
220+ }
221+
222+ #[ test]
223+ fn can_estimate_nothing_for_a_job_which_cannot_change_shift ( ) {
224+ // the job has nowhere else to go, so charging its shift would only add noise
225+ let fleet = create_fleet ( 5 ) ;
226+ let objective = create_pinning_objective ( & fleet) ;
227+ let route_ctx = create_route_ctx_at ( & fleet, 4 , vec ! [ ] ) ;
228+ let solution_ctx = TestInsertionContextBuilder :: default ( ) . build ( ) . solution ;
229+ let job = TestSingleBuilder :: default ( ) . location ( Some ( PINNED_LOCATION ) ) . build_as_job_ref ( ) ;
230+
231+ let result = objective. estimate ( & MoveContext :: route ( & solution_ctx, & route_ctx, & job) ) ;
232+
233+ assert_eq ! ( result, 0. ) ;
234+ }
235+
236+ parameterized_test ! { can_estimate_fitness_ignoring_unmovable_work, ( days, expected) , {
237+ can_estimate_fitness_ignoring_unmovable_work_impl( days, expected) ;
238+ } }
239+
240+ can_estimate_fitness_ignoring_unmovable_work ! {
241+ case_01_a_shift_of_standing_work_alone_is_free: ( vec![ ( 4 , vec![ PINNED_LOCATION ] ) ] , 0. ) ,
242+ case_02_new_work_on_the_earliest_shift: ( vec![ ( 0 , vec![ 1 ] ) , ( 4 , vec![ PINNED_LOCATION ] ) ] , 0. ) ,
243+ case_03_new_work_joining_the_standing_shift: ( vec![ ( 4 , vec![ PINNED_LOCATION , 1 ] ) ] , 4. * DAY ) ,
244+ case_04_new_work_on_its_own_second_day: ( vec![ ( 1 , vec![ 1 ] ) , ( 4 , vec![ PINNED_LOCATION ] ) ] , DAY ) ,
245+ // standing work on a shift which is worked anyway changes nothing
246+ case_05_mixed_shift_costs_its_delay_once: ( vec![ ( 1 , vec![ PINNED_LOCATION , 1 , 2 ] ) ] , DAY ) ,
247+ }
248+
249+ fn can_estimate_fitness_ignoring_unmovable_work_impl ( days : Vec < ( usize , Vec < Location > ) > , expected : Cost ) {
250+ let fleet = create_fleet ( 5 ) ;
251+ let objective = create_pinning_objective ( & fleet) ;
252+
253+ let result = get_fitness ( & objective, & fleet, days) ;
254+
255+ assert_eq ! ( result, expected) ;
256+ }
257+
258+ #[ test]
259+ fn can_prefer_an_earlier_empty_shift_over_one_held_open_by_standing_work ( ) {
260+ // the regression this rule exists for: a recurring Thursday appointment must not make Thursday
261+ // a free home for work which could have been done on Tuesday. Counting the pinned shift makes
262+ // joining it score delay(Thu) against delay(Tue) + delay(Thu), so joining always wins.
263+ let fleet = create_fleet ( 5 ) ;
264+ let objective = create_pinning_objective ( & fleet) ;
265+
266+ let join_standing_shift = get_fitness ( & objective, & fleet, vec ! [ ( 4 , vec![ PINNED_LOCATION , 1 ] ) ] ) ;
267+ let open_earlier_shift = get_fitness ( & objective, & fleet, vec ! [ ( 1 , vec![ 1 ] ) , ( 4 , vec![ PINNED_LOCATION ] ) ] ) ;
268+
269+ assert ! (
270+ open_earlier_shift < join_standing_shift,
271+ "expected opening the earlier shift ({open_earlier_shift}) to beat joining the standing one ({join_standing_shift})"
272+ ) ;
273+ }
0 commit comments