game/ungap/
predict.rs

1use std::collections::HashSet;
2
3use crate::ID;
4use abstio::Manifest;
5use abstutil::{prettyprint_bytes, prettyprint_usize, Counter, Timer};
6use geom::{Distance, Duration, UnitFmt};
7use map_gui::tools::{percentage_bar, ColorNetwork};
8use map_model::{PathRequest, PathStepV2, RoadID};
9use synthpop::{Scenario, TripEndpoint, TripMode};
10use widgetry::mapspace::ToggleZoomed;
11use widgetry::tools::{open_browser, FileLoader};
12use widgetry::{EventCtx, GfxCtx, Line, Outcome, Panel, Spinner, State, Text, TextExt, Widget};
13
14use crate::app::{App, Transition};
15use crate::ungap::{Layers, Tab, TakeLayers};
16
17pub struct ShowGaps {
18    top_panel: Panel,
19    layers: Layers,
20    tooltip: Option<Text>,
21}
22
23impl TakeLayers for ShowGaps {
24    fn take_layers(self) -> Layers {
25        self.layers
26    }
27}
28
29impl ShowGaps {
30    pub fn new_state(ctx: &mut EventCtx, app: &mut App, layers: Layers) -> Box<dyn State<App>> {
31        Box::new(ShowGaps {
32            top_panel: make_top_panel(ctx, app),
33            layers,
34            tooltip: None,
35        })
36    }
37}
38
39impl State<App> for ShowGaps {
40    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
41        ctx.canvas_movement();
42        if ctx.redo_mouseover() {
43            self.tooltip = None;
44            if let Some(data) = app.session.mode_shift.value() {
45                if let Some(r) = match app.mouseover_unzoomed_roads_and_intersections(ctx) {
46                    Some(ID::Road(r)) => Some(r),
47                    Some(ID::Lane(l)) => Some(l.road),
48                    _ => None,
49                } {
50                    let count = data.gaps.count_per_road.get(r);
51                    if count > 0 {
52                        // TODO Word more precisely... or less verbosely.
53                        self.tooltip = Some(Text::from(Line(format!(
54                            "{} trips might cross this high-stress road",
55                            prettyprint_usize(count)
56                        ))));
57                    }
58                }
59            }
60        }
61
62        match self.top_panel.event(ctx) {
63            Outcome::Clicked(x) => {
64                if x == "read about how this prediction works" {
65                    open_browser("https://a-b-street.github.io/docs/software/ungap_the_map/tech_details.html#predict-impact");
66                    return Transition::Keep;
67                } else if x == "Calculate" {
68                    let change_key = app.primary.map.get_edits_change_key();
69                    let map_name = app.primary.map.get_name().clone();
70                    let scenario_name = Scenario::default_scenario_for_map(&map_name);
71                    return Transition::Push(FileLoader::<App, Scenario>::new_state(
72                        ctx,
73                        abstio::path_scenario(&map_name, &scenario_name),
74                        Box::new(move |ctx, app, timer, maybe_scenario| {
75                            // TODO Handle corrupt files
76                            let scenario = maybe_scenario.unwrap();
77                            let data = ModeShiftData::from_scenario(ctx, app, scenario, timer);
78                            app.session.mode_shift.set((map_name, change_key), data);
79
80                            Transition::Multi(vec![
81                                Transition::Pop,
82                                Transition::ConsumeState(Box::new(|state, ctx, app| {
83                                    let state = state.downcast::<ShowGaps>().ok().unwrap();
84                                    vec![ShowGaps::new_state(ctx, app, state.take_layers())]
85                                })),
86                            ])
87                        }),
88                    ));
89                }
90
91                return Tab::PredictImpact
92                    .handle_action::<ShowGaps>(ctx, app, &x)
93                    .unwrap();
94            }
95            Outcome::Changed(_) => {
96                let (map_name, mut data) = app.session.mode_shift.take().unwrap();
97                data.filters = Filters::from_controls(&self.top_panel);
98                ctx.loading_screen("update mode shift", |ctx, timer| {
99                    data.recalculate_gaps(ctx, app, timer)
100                });
101                app.session.mode_shift.set(map_name, data);
102                // TODO This is heavy-handed for just updating the counters
103                self.top_panel = make_top_panel(ctx, app);
104            }
105            _ => {}
106        }
107
108        if let Some(t) = self.layers.event(ctx, app) {
109            return t;
110        }
111
112        Transition::Keep
113    }
114
115    fn draw(&self, g: &mut GfxCtx, app: &App) {
116        self.top_panel.draw(g);
117        self.layers.draw(g, app);
118
119        if let Some(data) = app.session.mode_shift.value() {
120            data.gaps.draw.draw(g);
121        }
122        if let Some(ref txt) = self.tooltip {
123            g.draw_mouse_tooltip(txt.clone());
124        }
125    }
126}
127
128fn make_top_panel(ctx: &mut EventCtx, app: &App) -> Panel {
129    let map_name = app.primary.map.get_name().clone();
130    let change_key = app.primary.map.get_edits_change_key();
131    let col;
132
133    if app.session.mode_shift.key().as_ref() == Some(&(map_name.clone(), change_key)) {
134        let data = app.session.mode_shift.value().unwrap();
135
136        col = vec![
137            ctx.style()
138                .btn_plain
139                .icon_text(
140                    "system/assets/tools/info.svg",
141                    "How many drivers might switch to biking?",
142                )
143                .build_widget(ctx, "read about how this prediction works"),
144            percentage_bar(
145                ctx,
146                Text::from(Line(format!(
147                    "{} total driving trips in this area",
148                    prettyprint_usize(data.all_candidate_trips.len())
149                ))),
150                0.0,
151            ),
152            Widget::col(vec![
153                "Who might cycle if it was safer?".text_widget(ctx),
154                data.filters.to_controls(ctx),
155                percentage_bar(
156                    ctx,
157                    Text::from(Line(format!(
158                        "{} / {} trips, based on these thresholds",
159                        prettyprint_usize(data.filtered_trips.len()),
160                        prettyprint_usize(data.all_candidate_trips.len())
161                    ))),
162                    pct(data.filtered_trips.len(), data.all_candidate_trips.len()),
163                ),
164            ])
165            .section(ctx),
166            Widget::col(vec![
167                "How many would switch based on your proposal?".text_widget(ctx),
168                percentage_bar(
169                    ctx,
170                    Text::from(Line(format!(
171                        "{} / {} trips would switch",
172                        prettyprint_usize(data.results.num_trips),
173                        prettyprint_usize(data.all_candidate_trips.len())
174                    ))),
175                    pct(data.results.num_trips, data.all_candidate_trips.len()),
176                ),
177                data.results.describe().into_widget(ctx),
178            ])
179            .section(ctx),
180        ];
181    } else {
182        let scenario_name = Scenario::default_scenario_for_map(&map_name);
183        if scenario_name == "home_to_work" {
184            col =
185                vec!["This city doesn't have travel demand model data available".text_widget(ctx)];
186        } else {
187            let size = Manifest::load()
188                .get_entry(&abstio::path_scenario(&map_name, &scenario_name))
189                .map(|entry| prettyprint_bytes(entry.compressed_size_bytes))
190                .unwrap_or_else(|| "???".to_string());
191            col = vec![
192                Text::from_multiline(vec![
193                    Line("Predicting impact of your proposal may take a moment."),
194                    Line("The application may freeze up during that time."),
195                    Line(format!("We need to load a {} file", size)),
196                ])
197                .into_widget(ctx),
198                ctx.style()
199                    .btn_solid_primary
200                    .text("Calculate")
201                    .build_def(ctx),
202            ];
203        }
204    }
205
206    Tab::PredictImpact.make_left_panel(ctx, app, Widget::col(col))
207}
208
209// TODO For now, it's easier to just copy pieces from sandbox/dashboards/mode_shift.rs. I'm not
210// sure how these two tools will interact yet, so not worth trying to refactor anything. One works
211// off Scenario files directly, the other off an instantiated Scenario.
212
213pub struct ModeShiftData {
214    // Calculated from the unedited map, not yet filtered.
215    all_candidate_trips: Vec<CandidateTrip>,
216    filters: Filters,
217    // From the unedited map, filtered
218    gaps: NetworkGaps,
219    // Indices into all_candidate_trips
220    filtered_trips: Vec<usize>,
221    results: Results,
222}
223
224struct CandidateTrip {
225    bike_req: PathRequest,
226    estimated_biking_time: Duration,
227    driving_distance: Distance,
228    total_elevation_gain: Distance,
229}
230
231struct Filters {
232    max_biking_time: Duration,
233    max_elevation_gain: Distance,
234}
235
236struct NetworkGaps {
237    draw: ToggleZoomed,
238    count_per_road: Counter<RoadID>,
239}
240
241// Of the filtered trips, which cross at least 1 edited road?
242// TODO Many ways of defining this... maybe the edits need to plug the gap on at least 50% of
243// stressful roads encountered by this trip?
244struct Results {
245    num_trips: usize,
246    total_driving_distance: Distance,
247    annual_co2_emissions_tons: f64,
248}
249
250impl Filters {
251    fn default() -> Self {
252        Self {
253            max_biking_time: Duration::minutes(30),
254            max_elevation_gain: Distance::feet(100.0),
255        }
256    }
257
258    fn apply(&self, x: &CandidateTrip) -> bool {
259        x.estimated_biking_time <= self.max_biking_time
260            && x.total_elevation_gain <= self.max_elevation_gain
261    }
262
263    fn to_controls(&self, ctx: &mut EventCtx) -> Widget {
264        Widget::col(vec![
265            Widget::row(vec![
266                "Max biking time".text_widget(ctx).centered_vert(),
267                Spinner::widget(
268                    ctx,
269                    "max_biking_time",
270                    (Duration::ZERO, Duration::hours(12)),
271                    self.max_biking_time,
272                    Duration::minutes(1),
273                ),
274            ]),
275            Widget::row(vec![
276                "Max elevation gain".text_widget(ctx).centered_vert(),
277                Spinner::widget_with_custom_rendering(
278                    ctx,
279                    "max_elevation_gain",
280                    (Distance::ZERO, Distance::meters(500.0)),
281                    self.max_elevation_gain,
282                    Distance::meters(1.0),
283                    // Even if the user's settings are set to feet, our step size is in meters, so
284                    // just render in meters.
285                    Box::new(|x| x.to_string(&UnitFmt::metric())),
286                ),
287            ]),
288        ])
289    }
290
291    fn from_controls(panel: &Panel) -> Filters {
292        Filters {
293            max_biking_time: panel.spinner("max_biking_time"),
294            max_elevation_gain: panel.spinner("max_elevation_gain"),
295        }
296    }
297}
298
299impl Results {
300    fn default() -> Self {
301        Self {
302            num_trips: 0,
303            total_driving_distance: Distance::ZERO,
304            annual_co2_emissions_tons: 0.0,
305        }
306    }
307
308    fn describe(&self) -> Text {
309        let mut txt = Text::new();
310        txt.add_line(Line(format!(
311            "{} total vehicle miles traveled daily, now eliminated",
312            prettyprint_usize(self.total_driving_distance.to_miles() as usize)
313        )));
314        // Round to 1 decimal place
315        let tons = (self.annual_co2_emissions_tons * 10.0).round() / 10.0;
316        txt.add_line(Line(format!(
317            "{} tons of CO2 emissions saved annually",
318            tons
319        )));
320        txt
321    }
322}
323
324impl ModeShiftData {
325    fn empty(ctx: &mut EventCtx) -> Self {
326        Self {
327            all_candidate_trips: Vec::new(),
328            filters: Filters::default(),
329            gaps: NetworkGaps {
330                draw: ToggleZoomed::empty(ctx),
331                count_per_road: Counter::new(),
332            },
333            filtered_trips: Vec::new(),
334            results: Results::default(),
335        }
336    }
337
338    fn from_scenario(
339        ctx: &mut EventCtx,
340        app: &App,
341        scenario: Scenario,
342        timer: &mut Timer,
343    ) -> ModeShiftData {
344        let unedited_map = app
345            .secondary
346            .as_ref()
347            .map(|x| &x.map)
348            .unwrap_or(&app.primary.map);
349        let all_candidate_trips = timer
350            .parallelize(
351                "analyze trips",
352                scenario
353                    .all_trips()
354                    .filter(|trip| {
355                        trip.mode == TripMode::Drive
356                            && matches!(trip.origin, TripEndpoint::Building(_))
357                            && matches!(trip.destination, TripEndpoint::Building(_))
358                    })
359                    .collect(),
360                |trip| {
361                    // TODO Does ? work
362                    if let (Some(driving_path), Some(biking_path)) = (
363                        TripEndpoint::path_req(
364                            trip.origin,
365                            trip.destination,
366                            TripMode::Drive,
367                            unedited_map,
368                        )
369                        .and_then(|req| unedited_map.pathfind(req).ok()),
370                        TripEndpoint::path_req(
371                            trip.origin,
372                            trip.destination,
373                            TripMode::Bike,
374                            unedited_map,
375                        )
376                        .and_then(|req| unedited_map.pathfind(req).ok()),
377                    ) {
378                        let (total_elevation_gain, _) =
379                            biking_path.get_total_elevation_change(unedited_map);
380                        Some(CandidateTrip {
381                            bike_req: biking_path.get_req().clone(),
382                            estimated_biking_time: biking_path
383                                .estimate_duration(unedited_map, Some(map_model::MAX_BIKE_SPEED)),
384                            driving_distance: driving_path.total_length(),
385                            total_elevation_gain,
386                        })
387                    } else {
388                        None
389                    }
390                },
391            )
392            .into_iter()
393            .flatten()
394            .collect();
395        let mut data = ModeShiftData::empty(ctx);
396        data.all_candidate_trips = all_candidate_trips;
397        data.recalculate_gaps(ctx, app, timer);
398        data
399    }
400
401    fn recalculate_gaps(&mut self, ctx: &mut EventCtx, app: &App, timer: &mut Timer) {
402        let unedited_map = app
403            .secondary
404            .as_ref()
405            .map(|x| &x.map)
406            .unwrap_or(&app.primary.map);
407
408        // Find all high-stress roads, since we'll filter by them next
409        let mut high_stress = HashSet::new();
410        for r in unedited_map.all_roads() {
411            for dr in r.id.both_directions() {
412                if r.high_stress_for_bikes(unedited_map, dr.dir) {
413                    high_stress.insert(dr);
414                }
415            }
416        }
417
418        self.filtered_trips.clear();
419        let mut filtered_requests = Vec::new();
420        for (idx, trip) in self.all_candidate_trips.iter().enumerate() {
421            if self.filters.apply(trip) {
422                self.filtered_trips.push(idx);
423                filtered_requests.push((idx, trip.bike_req.clone()));
424            }
425        }
426
427        self.results = Results::default();
428
429        let mut count_per_road = Counter::new();
430        for (idx, path) in timer
431            .parallelize("calculate routes", filtered_requests, |(idx, req)| {
432                unedited_map.pathfind_v2(req).map(|path| (idx, path))
433            })
434            .into_iter()
435            .flatten()
436        {
437            let mut crosses_edited_road = false;
438            for step in path.get_steps() {
439                // No Contraflow steps for bike paths
440                if let PathStepV2::Along(dr) = step {
441                    if high_stress.contains(dr) {
442                        count_per_road.inc(dr.road);
443
444                        // TODO Assumes the edits have made the road stop being high stress!
445                        if !crosses_edited_road
446                            && app
447                                .primary
448                                .map
449                                .get_edits()
450                                .original_roads
451                                .contains_key(&dr.road)
452                        {
453                            crosses_edited_road = true;
454                        }
455                    }
456                }
457            }
458            if crosses_edited_road {
459                self.results.num_trips += 1;
460                self.results.total_driving_distance +=
461                    self.all_candidate_trips[idx].driving_distance;
462            }
463        }
464
465        // Assume this trip happens 5 times a week, 52 weeks a year.
466        let annual_mileage = 5.0 * 52.0 * self.results.total_driving_distance.to_miles();
467        // https://www.epa.gov/greenvehicles/greenhouse-gas-emissions-typical-passenger-vehicle#driving
468        // says 404 grams per mile.
469        // And convert grams to tons
470        self.results.annual_co2_emissions_tons = 404.0 * annual_mileage / 907185.0;
471
472        let mut colorer = ColorNetwork::no_fading(app);
473        colorer.ranked_roads(count_per_road.clone(), &app.cs.good_to_bad_red);
474        self.gaps = NetworkGaps {
475            draw: colorer.build(ctx),
476            count_per_road,
477        };
478    }
479}
480
481fn pct(value: usize, total: usize) -> f64 {
482    if total == 0 {
483        1.0
484    } else {
485        value as f64 / total as f64
486    }
487}