1use std::collections::BTreeSet;
2
3use anyhow::Result;
4use maplit::btreeset;
5
6use crate::ID;
7use abstutil::{prettyprint_usize, Counter};
8use geom::{Circle, Distance, Duration, Percent, Polygon, Pt2D, Time};
9use map_gui::tools::ColorNetwork;
10use map_model::{IntersectionID, Map, Traversable};
11use sim::{AgentType, VehicleType};
12use widgetry::mapspace::ToggleZoomed;
13use widgetry::mapspace::{DummyID, World};
14use widgetry::tools::{ColorLegend, DivergingScale, PopupMsg};
15use widgetry::{Color, EventCtx, GfxCtx, Line, Outcome, Panel, Text, TextExt, Toggle, Widget};
16
17use crate::app::{App, Transition};
18use crate::layer::{header, Layer, LayerOutcome, PANEL_PLACEMENT};
19use crate::render::unzoomed_agent_radius;
20
21pub struct Backpressure {
22 time: Time,
23 draw: ToggleZoomed,
24 panel: Panel,
25}
26
27impl Layer for Backpressure {
28 fn name(&self) -> Option<&'static str> {
29 Some("backpressure")
30 }
31 fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
32 if app.primary.sim.time() != self.time {
33 *self = Backpressure::new(ctx, app);
34 }
35
36 <dyn Layer>::simple_event(ctx, &mut self.panel)
37 }
38 fn draw(&self, g: &mut GfxCtx, _: &App) {
39 self.panel.draw(g);
40 self.draw.draw(g);
41 }
42 fn draw_minimap(&self, g: &mut GfxCtx) {
43 g.redraw(&self.draw.unzoomed);
44 }
45}
46
47impl Backpressure {
48 pub fn new(ctx: &mut EventCtx, app: &App) -> Backpressure {
49 let mut cnt_per_r = Counter::new();
50 let mut cnt_per_i = Counter::new();
51 for path in app.primary.sim.get_all_driving_paths() {
52 for step in path.get_steps() {
53 match step.as_traversable() {
54 Traversable::Lane(l) => {
55 cnt_per_r.inc(l.road);
56 }
57 Traversable::Turn(t) => {
58 cnt_per_i.inc(t.parent);
59 }
60 }
61 }
62 }
63
64 let panel = Panel::new_builder(Widget::col(vec![
65 header(ctx, "Backpressure"),
66 Text::from(
67 Line("This counts all active trips passing through a road in the future")
68 .secondary(),
69 )
70 .wrap_to_pct(ctx, 15)
71 .into_widget(ctx),
72 ColorLegend::gradient(
73 ctx,
74 &app.cs.good_to_bad_red,
75 vec!["lowest count", "highest"],
76 ),
77 ]))
78 .aligned_pair(PANEL_PLACEMENT)
79 .build(ctx);
80
81 let mut colorer = ColorNetwork::new(app);
82 colorer.pct_roads(cnt_per_r, &app.cs.good_to_bad_red);
83 colorer.pct_intersections(cnt_per_i, &app.cs.good_to_bad_red);
84
85 Backpressure {
86 time: app.primary.sim.time(),
87 draw: colorer.build(ctx),
88 panel,
89 }
90 }
91}
92
93pub struct Throughput {
94 time: Time,
95 agent_types: BTreeSet<AgentType>,
96 tooltip: Option<Text>,
97 draw: ToggleZoomed,
98 panel: Panel,
99}
100
101impl Layer for Throughput {
102 fn name(&self) -> Option<&'static str> {
103 Some("throughput")
104 }
105 fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
106 let mut recalc_tooltip = false;
107 if app.primary.sim.time() != self.time {
108 *self = Throughput::new(ctx, app, self.agent_types.clone());
109 recalc_tooltip = true;
110 }
111
112 if ctx.canvas.is_unzoomed() {
114 if ctx.redo_mouseover() || recalc_tooltip {
115 self.tooltip = None;
116 match app.mouseover_unzoomed_roads_and_intersections(ctx) {
117 Some(ID::Road(r)) => {
118 let cnt = app
119 .primary
120 .sim
121 .get_analytics()
122 .road_thruput
123 .total_for_with_agent_types(r, self.agent_types.clone());
124 if cnt > 0 {
125 self.tooltip = Some(Text::from(prettyprint_usize(cnt)));
126 }
127 }
128 Some(ID::Intersection(i)) => {
129 let cnt = app
130 .primary
131 .sim
132 .get_analytics()
133 .intersection_thruput
134 .total_for_with_agent_types(i, self.agent_types.clone());
135 if cnt > 0 {
136 self.tooltip = Some(Text::from(prettyprint_usize(cnt)));
137 }
138 }
139 _ => {}
140 }
141 }
142 } else {
143 self.tooltip = None;
144 }
145
146 match self.panel.event(ctx) {
147 Outcome::Clicked(x) => match x.as_ref() {
148 "close" => {
149 return Some(LayerOutcome::Close);
150 }
151 "Export to CSV" => {
152 return Some(LayerOutcome::Transition(Transition::Push(
153 match export_throughput(app) {
154 Ok((path1, path2)) => PopupMsg::new_state(
155 ctx,
156 "Data exported",
157 vec![format!("Data exported to {} and {}", path1, path2)],
158 ),
159 Err(err) => {
160 PopupMsg::new_state(ctx, "Export failed", vec![err.to_string()])
161 }
162 },
163 )));
164 }
165 _ => unreachable!(),
166 },
167 Outcome::Changed(_) => {
168 if self
169 .panel
170 .maybe_is_checked("Compare before proposal")
171 .unwrap_or(false)
172 {
173 return Some(LayerOutcome::Replace(Box::new(CompareThroughput::new(
174 ctx, app,
175 ))));
176 }
177
178 let mut agent_types = BTreeSet::new();
179 for agent_type in AgentType::all() {
180 if self.panel.is_checked(agent_type.noun()) {
181 agent_types.insert(agent_type);
182 }
183 }
184
185 return Some(LayerOutcome::Replace(Box::new(Throughput::new(
186 ctx,
187 app,
188 agent_types,
189 ))));
190 }
191 _ => {}
192 }
193 None
194 }
195 fn draw(&self, g: &mut GfxCtx, _: &App) {
196 self.panel.draw(g);
197 self.draw.draw(g);
198 if let Some(ref txt) = self.tooltip {
199 g.draw_mouse_tooltip(txt.clone());
200 }
201 }
202 fn draw_minimap(&self, g: &mut GfxCtx) {
203 g.redraw(&self.draw.unzoomed);
204 }
205}
206
207impl Throughput {
208 pub fn new(ctx: &mut EventCtx, app: &App, agent_types: BTreeSet<AgentType>) -> Throughput {
209 let stats = &app.primary.sim.get_analytics();
210 let road_counter = stats.road_thruput.all_total_counts(&agent_types);
211 let intersection_counter = stats.intersection_thruput.all_total_counts(&agent_types);
212 let panel = Panel::new_builder(Widget::col(vec![
213 header(ctx, "Throughput"),
214 Text::from(Line("This counts all people crossing since midnight").secondary())
215 .wrap_to_pct(ctx, 15)
216 .into_widget(ctx),
217 if app.has_prebaked().is_some() {
218 Toggle::switch(ctx, "Compare before proposal", None, false)
219 } else {
220 Widget::nothing()
221 },
222 Widget::custom_row(
223 AgentType::all()
224 .into_iter()
225 .map(|agent_type| {
226 Toggle::checkbox(
227 ctx,
228 agent_type.noun(),
229 None,
230 agent_types.contains(&agent_type),
231 )
232 })
233 .collect(),
234 )
235 .flex_wrap(ctx, Percent::int(20)),
236 ColorLegend::gradient(ctx, &app.cs.good_to_bad_red, vec!["0", "highest"]),
237 ctx.style().btn_plain.text("Export to CSV").build_def(ctx),
238 ]))
239 .aligned_pair(PANEL_PLACEMENT)
240 .build(ctx);
241
242 let mut colorer = ColorNetwork::new(app);
243 colorer.ranked_roads(road_counter, &app.cs.good_to_bad_red);
244 colorer.ranked_intersections(intersection_counter, &app.cs.good_to_bad_red);
245
246 Throughput {
247 time: app.primary.sim.time(),
248 agent_types,
249 tooltip: None,
250 draw: colorer.build(ctx),
251 panel,
252 }
253 }
254}
255
256pub struct CompareThroughput {
257 time: Time,
258 tooltip: Option<Text>,
259 draw: ToggleZoomed,
260 panel: Panel,
261}
262
263impl Layer for CompareThroughput {
264 fn name(&self) -> Option<&'static str> {
265 Some("throughput")
266 }
267 fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
268 let mut recalc_tooltip = false;
269 if app.primary.sim.time() != self.time {
270 *self = CompareThroughput::new(ctx, app);
271 recalc_tooltip = true;
272 }
273
274 if ctx.canvas.is_unzoomed() {
276 if ctx.redo_mouseover() || recalc_tooltip {
277 self.tooltip = None;
278 match app.mouseover_unzoomed_roads_and_intersections(ctx) {
279 Some(ID::Road(r)) => {
280 let after = app.primary.sim.get_analytics().road_thruput.total_for(r);
281 let before = app
282 .prebaked()
283 .road_thruput
284 .total_for_by_time(r, app.primary.sim.time());
285 if before + after > 0 {
286 self.tooltip = Some(Text::from(format!(
287 "{} before, {} after",
288 prettyprint_usize(before),
289 prettyprint_usize(after)
290 )));
291 }
292 }
293 Some(ID::Intersection(i)) => {
294 let after = app
295 .primary
296 .sim
297 .get_analytics()
298 .intersection_thruput
299 .total_for(i);
300 let before = app
301 .prebaked()
302 .intersection_thruput
303 .total_for_by_time(i, app.primary.sim.time());
304 if before + after > 0 {
305 self.tooltip = Some(Text::from(format!(
306 "{} before, {} after",
307 prettyprint_usize(before),
308 prettyprint_usize(after)
309 )));
310 }
311 }
312 _ => {}
313 }
314 }
315 } else {
316 self.tooltip = None;
317 }
318
319 match self.panel.event(ctx) {
320 Outcome::Clicked(x) => match x.as_ref() {
321 "close" => {
322 return Some(LayerOutcome::Close);
323 }
324 _ => unreachable!(),
325 },
326 Outcome::Changed(_) => {
327 return Some(LayerOutcome::Replace(Box::new(Throughput::new(
328 ctx,
329 app,
330 AgentType::all().into_iter().collect(),
331 ))));
332 }
333 _ => {}
334 }
335 None
336 }
337 fn draw(&self, g: &mut GfxCtx, _: &App) {
338 self.panel.draw(g);
339 self.draw.draw(g);
340 if let Some(ref txt) = self.tooltip {
341 g.draw_mouse_tooltip(txt.clone());
342 }
343 }
344 fn draw_minimap(&self, g: &mut GfxCtx) {
345 g.redraw(&self.draw.unzoomed);
346 }
347}
348
349impl CompareThroughput {
350 pub fn new(ctx: &mut EventCtx, app: &App) -> CompareThroughput {
351 let after = app.primary.sim.get_analytics();
352 let before = app.prebaked();
353 let hour = app.primary.sim.time().get_hours();
354
355 let mut after_road = Counter::new();
356 let mut before_road = Counter::new();
357 {
358 for ((r, _, _), count) in &after.road_thruput.counts {
359 after_road.add(*r, *count);
360 }
361 for ((r, _, hr), count) in &before.road_thruput.counts {
363 if *hr <= hour {
364 before_road.add(*r, *count);
365 }
366 }
367 }
368 let mut after_intersection = Counter::new();
369 let mut before_intersection = Counter::new();
370 {
371 for ((i, _, _), count) in &after.intersection_thruput.counts {
372 after_intersection.add(*i, *count);
373 }
374 for ((i, _, hr), count) in &before.intersection_thruput.counts {
376 if *hr <= hour {
377 before_intersection.add(*i, *count);
378 }
379 }
380 }
381
382 let mut colorer = ColorNetwork::new(app);
383
384 let scale = DivergingScale::new(Color::hex("#5D9630"), Color::WHITE, Color::hex("#A32015"))
385 .range(0.0, 2.0)
386 .ignore(0.7, 1.3);
387
388 for (r, before, after) in before_road.compare(after_road) {
389 if let Some(c) = scale.eval((after as f64) / (before as f64)) {
390 colorer.add_r(r, c);
391 }
392 }
393 for (i, before, after) in before_intersection.compare(after_intersection) {
394 if let Some(c) = scale.eval((after as f64) / (before as f64)) {
395 colorer.add_i(i, c);
396 }
397 }
398
399 let panel = Panel::new_builder(Widget::col(vec![
400 header(ctx, "Relative Throughput"),
401 Toggle::switch(ctx, "Compare before proposal", None, true),
402 scale.make_legend(ctx, vec!["less traffic", "same", "more"]),
403 ]))
404 .aligned_pair(PANEL_PLACEMENT)
405 .build(ctx);
406
407 CompareThroughput {
408 time: app.primary.sim.time(),
409 tooltip: None,
410 draw: colorer.build(ctx),
411 panel,
412 }
413 }
414}
415
416pub struct TrafficJams {
417 time: Time,
418 draw: ToggleZoomed,
419 panel: Panel,
420}
421
422impl Layer for TrafficJams {
423 fn name(&self) -> Option<&'static str> {
424 Some("traffic jams")
425 }
426 fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
427 if app.primary.sim.time() != self.time {
428 *self = TrafficJams::new(ctx, app);
429 }
430
431 <dyn Layer>::simple_event(ctx, &mut self.panel)
432 }
433 fn draw(&self, g: &mut GfxCtx, _: &App) {
434 self.panel.draw(g);
435 self.draw.draw(g);
436 }
437 fn draw_minimap(&self, g: &mut GfxCtx) {
438 g.redraw(&self.draw.unzoomed);
439 }
440}
441
442impl TrafficJams {
443 pub fn new(ctx: &mut EventCtx, app: &App) -> TrafficJams {
444 let mut draw = ToggleZoomed::builder();
446 draw.unzoomed.push(
447 app.cs.fade_map_dark,
448 app.primary.map.get_boundary_polygon().clone(),
449 );
450 let mut cnt = 0;
451 for (epicenter, boundary) in cluster_jams(
452 &app.primary.map,
453 app.primary.sim.delayed_intersections(Duration::minutes(5)),
454 ) {
455 cnt += 1;
456 draw.unzoomed
457 .push(Color::RED, boundary.to_outline(Distance::meters(5.0)));
458 draw.unzoomed.push(Color::RED.alpha(0.5), boundary.clone());
459 draw.unzoomed.push(Color::WHITE, epicenter.clone());
460
461 draw.zoomed.push(
462 Color::RED.alpha(0.4),
463 boundary.to_outline(Distance::meters(5.0)),
464 );
465 draw.zoomed.push(Color::RED.alpha(0.3), boundary);
466 draw.zoomed.push(Color::WHITE.alpha(0.4), epicenter);
467 }
468
469 let panel = Panel::new_builder(Widget::col(vec![
470 header(ctx, "Traffic jams"),
471 Text::from(
472 Line("A jam starts when delay exceeds 5 mins, then spreads out").secondary(),
473 )
474 .wrap_to_pct(ctx, 15)
475 .into_widget(ctx),
476 format!("{} jams detected", cnt).text_widget(ctx),
477 ]))
478 .aligned_pair(PANEL_PLACEMENT)
479 .build(ctx);
480
481 TrafficJams {
482 time: app.primary.sim.time(),
483 draw: draw.build(ctx),
484 panel,
485 }
486 }
487}
488
489struct Jam {
490 epicenter: IntersectionID,
491 members: BTreeSet<IntersectionID>,
492}
493
494fn cluster_jams(map: &Map, problems: Vec<(IntersectionID, Time)>) -> Vec<(Polygon, Polygon)> {
496 let mut jams: Vec<Jam> = Vec::new();
497 for (i, _) in problems {
499 if let Some(ref mut jam) = jams.iter_mut().find(|j| j.adjacent_to(map, i)) {
501 jam.members.insert(i);
502 } else {
503 jams.push(Jam {
504 epicenter: i,
505 members: btreeset! { i },
506 });
507 }
508 }
509
510 jams.into_iter()
513 .filter_map(|jam| {
514 let epicenter = map.get_i(jam.epicenter).polygon.clone();
515 Polygon::convex_hull(jam.all_polygons(map))
516 .ok()
517 .map(move |entire_shape| (epicenter, entire_shape))
518 })
519 .collect()
520}
521
522impl Jam {
523 fn adjacent_to(&self, map: &Map, i: IntersectionID) -> bool {
524 for r in &map.get_i(i).roads {
525 let r = map.get_r(*r);
526 if self.members.contains(&r.src_i) || self.members.contains(&r.dst_i) {
527 return true;
528 }
529 }
530 false
531 }
532
533 fn all_polygons(self, map: &Map) -> Vec<Polygon> {
534 let mut polygons = Vec::new();
535 for i in self.members {
536 polygons.push(map.get_i(i).polygon.clone());
537 }
538 polygons
539 }
540}
541
542pub struct Delay {
544 time: Time,
545 draw: ToggleZoomed,
546 panel: Panel,
547}
548
549impl Layer for Delay {
550 fn name(&self) -> Option<&'static str> {
551 Some("delay")
552 }
553 fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
554 if app.primary.sim.time() != self.time {
555 *self = Delay::new(ctx, app);
556 }
557
558 if let Outcome::Clicked(x) = self.panel.event(ctx) {
559 match x.as_ref() {
560 "close" => {
561 return Some(LayerOutcome::Close);
562 }
563 _ => unreachable!(),
564 }
565 }
566 None
567 }
568 fn draw(&self, g: &mut GfxCtx, _: &App) {
569 self.panel.draw(g);
570 self.draw.draw(g);
571 }
572 fn draw_minimap(&self, g: &mut GfxCtx) {
573 g.redraw(&self.draw.unzoomed);
574 }
575}
576
577impl Delay {
578 pub fn new(ctx: &mut EventCtx, app: &App) -> Delay {
579 let mut delays = app.primary.sim.all_waiting_people();
580 let mut draw = ToggleZoomed::builder();
582 draw.unzoomed.push(
583 app.cs.fade_map_dark,
584 app.primary.map.get_boundary_polygon().clone(),
585 );
586 let car_circle = Circle::new(
588 Pt2D::new(0.0, 0.0),
589 unzoomed_agent_radius(Some(VehicleType::Car)),
590 )
591 .to_polygon();
592 let ped_circle = Circle::new(Pt2D::new(0.0, 0.0), unzoomed_agent_radius(None)).to_polygon();
593 for agent in app.primary.sim.get_unzoomed_agents(&app.primary.map) {
594 if let Some(delay) = agent.person.and_then(|p| delays.remove(&p)) {
595 let color = app
596 .cs
597 .good_to_bad_red
598 .eval((delay / Duration::minutes(15)).min(1.0));
599 if agent.id.to_vehicle_type().is_some() {
600 draw.unzoomed
601 .push(color, car_circle.translate(agent.pos.x(), agent.pos.y()));
602 } else {
603 draw.unzoomed
604 .push(color, ped_circle.translate(agent.pos.x(), agent.pos.y()));
605 }
606 }
607 }
608
609 Delay {
610 time: app.primary.sim.time(),
611 draw: draw.build(ctx),
612 panel: Panel::new_builder(Widget::col(vec![
613 header(ctx, "Delay per agent (minutes)"),
614 ColorLegend::gradient(ctx, &app.cs.good_to_bad_red, vec!["0", "5", "10", "15+"]),
615 ]))
616 .aligned_pair(PANEL_PLACEMENT)
617 .build(ctx),
618 }
619 }
620}
621
622pub struct PedestrianCrowding {
623 time: Time,
624 panel: Panel,
625 world: World<DummyID>,
626}
627
628impl Layer for PedestrianCrowding {
629 fn name(&self) -> Option<&'static str> {
630 Some("pedestrian crowding")
631 }
632 fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
633 if app.primary.sim.time() != self.time {
634 *self = Self::new(ctx, app);
635 }
636
637 if let Outcome::Clicked(x) = self.panel.event(ctx) {
638 match x.as_ref() {
639 "close" => {
640 return Some(LayerOutcome::Close);
641 }
642 _ => unreachable!(),
643 }
644 }
645
646 self.world.event(ctx);
648
649 None
650 }
651 fn draw(&self, g: &mut GfxCtx, _: &App) {
652 self.panel.draw(g);
653 self.world.draw(g);
654 }
655 fn draw_minimap(&self, _: &mut GfxCtx) {}
657}
658
659impl PedestrianCrowding {
660 pub fn new(ctx: &mut EventCtx, app: &App) -> Self {
661 let map = &app.primary.map;
662 let categories = vec![
663 ("1 - 1.5", app.cs.good_to_bad_red.eval(0.2)),
664 ("1.5 - 2", app.cs.good_to_bad_red.eval(0.4)),
665 ("2 - 5", app.cs.good_to_bad_red.eval(0.6)),
666 ("> 5", app.cs.good_to_bad_red.eval(1.0)),
667 ];
668 let mut world = World::new();
669 let mut draw = ToggleZoomed::builder();
670 draw.unzoomed
671 .push(app.cs.fade_map_dark, map.get_boundary_polygon().clone());
672 world.draw_master_batch(ctx, draw);
673
674 fn bucket(x: f64) -> &'static str {
675 if x < 1.0 {
676 unreachable!()
677 } else if x <= 1.5 {
678 "1 - 1.5"
679 } else if x <= 2.0 {
680 "1.5 - 2"
681 } else if x <= 5.0 {
682 "2 - 5"
683 } else {
684 "> 5"
685 }
686 }
687 fn round(x: f64) -> f64 {
688 (x * 10.0).ceil() / 10.0
690 }
691
692 let (roads, intersections) = app.primary.sim.get_pedestrian_density(map);
693 let mut max_density: f64 = 0.0;
694 for (r, density) in roads {
695 if density < 1.0 {
696 continue;
697 }
698 let density = round(density);
699 max_density = max_density.max(density);
700 let (_, color) = categories
701 .iter()
702 .find(|pair| pair.0 == bucket(density))
703 .unwrap();
704 world
705 .add_unnamed()
706 .hitbox(map.get_r(r).get_thick_polygon())
707 .draw_color_unzoomed(*color)
708 .invisibly_hoverable()
709 .tooltip(Text::from(format!("{density} people / m²")))
710 .build(ctx);
711 }
712 for (i, density) in intersections {
713 if density < 1.0 {
714 continue;
715 }
716 let density = round(density);
717 let (_, color) = categories
718 .iter()
719 .find(|pair| pair.0 == bucket(density))
720 .unwrap();
721 world
722 .add_unnamed()
723 .hitbox(map.get_i(i).polygon.clone())
724 .draw_color_unzoomed(*color)
725 .invisibly_hoverable()
726 .tooltip(Text::from(format!("{density} people / m²")))
727 .build(ctx);
728 }
729 world.initialize_hover(ctx);
730
731 Self {
732 time: app.primary.sim.time(),
733 world,
734 panel: Panel::new_builder(Widget::col(vec![
735 header(ctx, "Pedestrian crowding"),
736 "(people / m²)".text_widget(ctx),
737 format!("Max density: {max_density} m²").text_widget(ctx),
738 Widget::col(
739 categories
740 .into_iter()
741 .map(|(name, color)| ColorLegend::row(ctx, color, name))
742 .collect(),
743 ),
744 ]))
745 .aligned_pair(PANEL_PLACEMENT)
746 .build(ctx),
747 }
748 }
749}
750
751fn export_throughput(app: &App) -> Result<(String, String)> {
752 let path1 = format!(
753 "road_throughput_{}_{}.csv",
754 app.primary.map.get_name().as_filename(),
755 app.primary.sim.time().as_filename()
756 );
757 let path1 = abstio::write_file(
758 path1,
759 app.primary
760 .sim
761 .get_analytics()
762 .road_thruput
763 .export_csv(|id| id.0),
764 )?;
765
766 let path2 = format!(
767 "intersection_throughput_{}_{}.csv",
768 app.primary.map.get_name().as_filename(),
769 app.primary.sim.time().as_filename()
770 );
771 let path2 = abstio::write_file(
772 path2,
773 app.primary
774 .sim
775 .get_analytics()
776 .intersection_thruput
777 .export_csv(|id| id.0),
778 )?;
779
780 Ok((path1, path2))
781}