Skip to content

API Documentation

Core Classes

The fundamental classes that form the backbone of Network Wrangler:

Scenario objects manage how a collection of projects is applied to the networks.

Scenarios are built from a base scenario and a list of project cards.

A project card is a YAML file (or similar) that describes a change to the network. The project card can contain multiple changes, each of which is applied to the network in sequence.

Create a Scenario

Instantiate a scenario by seeding it with a base scenario and optionally some project cards.

from network_wrangler import create_scenario

my_scenario = create_scenario(
    base_scenario=my_base_year_scenario,
    card_search_dir=project_card_directory,
    filter_tags=["baseline2050"],
)

A base_year_scenario is a dictionary representation of key components of a scenario:

  • road_net: RoadwayNetwork instance
  • transit_net: TransitNetwork instance
  • applied_projects: list of projects that have been applied to the base scenario so that the scenario knows if there will be conflicts with future projects or if a future project’s pre-requisite is satisfied.
  • conflicts: dictionary of conflicts for project that have been applied to the base scenario so that the scenario knows if there will be conflicts with future projects.
my_base_year_scenario = {
    "road_net": load_from_roadway_dir(STPAUL_DIR),
    "transit_net": load_transit(STPAUL_DIR),
    "applied_projects": [],
    "conflicts": {},
}

Add Projects to a Scenario

In addition to adding projects when you create the scenario, project cards can be added to a scenario using the add_project_cards method.

from projectcard import read_cards

project_card_dict = read_cards(card_location, filter_tags=["Baseline2030"], recursive=True)
my_scenario.add_project_cards(project_card_dict.values())

Where card_location can be a single path, list of paths, a directory, or a glob pattern.

Apply Projects to a Scenario

Projects can be applied to a scenario using the apply_all_projects method. Before applying projects, the scenario will check that all pre-requisites are satisfied, that there are no conflicts, and that the projects are in the planned projects list.

If you want to check the order of projects before applying them, you can use the queued_projects prooperty.

my_scenario.queued_projects
my_scenario.apply_all_projects()

You can review the resulting scenario, roadway network, and transit networks.

my_scenario.applied_projects
my_scenario.road_net.links_gdf.explore()
my_scenario.transit_net.feed.shapes_gdf.explore()

Write a Scenario to Disk

Scenarios (and their networks) can be written to disk using the write method which in addition to writing out roadway and transit networks, will serialize the scenario to a yaml-like file and can also write out the project cards that have been applied.

my_scenario.write(
    "output_dir",
    "scenario_name_to_use",
    overwrite=True,
    projects_write=True,
    file_format="parquet",
)
Example Serialized Scenario File
applied_projects: &id001
- project a
- project b
base_scenario:
applied_projects: *id001
roadway:
    dir: /Users/elizabeth/Documents/urbanlabs/MetCouncil/NetworkWrangler/working/network_wrangler/examples/small
    file_format: geojson
transit:
    dir: /Users/elizabeth/Documents/urbanlabs/MetCouncil/NetworkWrangler/working/network_wrangler/examples/small
config:
CPU:
    EST_PD_READ_SPEED:
    csv: 0.03
    geojson: 0.03
    json: 0.15
    parquet: 0.005
    txt: 0.04
IDS:
    ML_LINK_ID_METHOD: range
    ML_LINK_ID_RANGE: &id002 !!python/tuple
    - 950000
    - 999999
    ML_LINK_ID_SCALAR: 15000
    ML_NODE_ID_METHOD: range
    ML_NODE_ID_RANGE: *id002
    ML_NODE_ID_SCALAR: 15000
    ROAD_SHAPE_ID_METHOD: scalar
    ROAD_SHAPE_ID_SCALAR: 1000
    TRANSIT_SHAPE_ID_METHOD: scalar
    TRANSIT_SHAPE_ID_SCALAR: 1000000
MODEL_ROADWAY:
    ADDITIONAL_COPY_FROM_GP_TO_ML: []
    ADDITIONAL_COPY_TO_ACCESS_EGRESS: []
    ML_OFFSET_METERS: -10
conflicts: {}
corequisites: {}
name: first_scenario
prerequisites: {}
roadway:
dir: /Users/elizabeth/Documents/urbanlabs/MetCouncil/NetworkWrangler/working/network_wrangler/tests/out/first_scenario/roadway
file_format: parquet
transit:
dir: /Users/elizabeth/Documents/urbanlabs/MetCouncil/NetworkWrangler/working/network_wrangler/tests/out/first_scenario/transit
file_format: txt

Load a scenario from disk

And if you want to reload scenario that you “wrote”, you can use the load_scenario function.

from network_wrangler import load_scenario

my_scenario = load_scenario("output_dir/scenario_name_to_use_scenario.yml")

network_wrangler.scenario.BASE_SCENARIO_SUGGESTED_PROPS module-attribute

BASE_SCENARIO_SUGGESTED_PROPS = [
    "road_net",
    "transit_net",
    "applied_projects",
    "conflicts",
]

List of card types that that will be applied to the transit network.

network_wrangler.scenario.ROADWAY_CARD_TYPES module-attribute

ROADWAY_CARD_TYPES = [
    "roadway_property_change",
    "roadway_deletion",
    "roadway_addition",
    "pycode",
]

List of card types that that will be applied to the transit network AFTER being applied to the roadway network.

network_wrangler.scenario.TRANSIT_CARD_TYPES module-attribute

TRANSIT_CARD_TYPES = [
    "transit_property_change",
    "transit_routing_change",
    "transit_route_addition",
    "transit_service_deletion",
]

List of card types that that will be applied to the roadway network.

network_wrangler.scenario.Scenario

Holds information about a scenario.

Typical usage example:

my_base_year_scenario = {
    "road_net": load_roadway(
        links_file=STPAUL_LINK_FILE,
        nodes_file=STPAUL_NODE_FILE,
        shapes_file=STPAUL_SHAPE_FILE,
    ),
    "transit_net": load_transit(STPAUL_DIR),
}

# create a future baseline scenario from base by searching for all cards in dir w/ baseline tag
project_card_directory = Path(STPAUL_DIR) / "project_cards"
my_scenario = create_scenario(
    base_scenario=my_base_year_scenario,
    card_search_dir=project_card_directory,
    filter_tags=["baseline2050"],
)

# check project card queue and then apply the projects
my_scenario.queued_projects
my_scenario.apply_all_projects()

# check applied projects, write it out, and create a summary report.
my_scenario.applied_projects
my_scenario.write("baseline")
my_scenario.summary

# Add some projects to create a build scenario based on a list of files.
build_card_filenames = [
    "3_multiple_roadway_attribute_change.yml",
    "road.prop_changes.segment.yml",
    "4_simple_managed_lane.yml",
]
my_scenario.add_projects_from_files(build_card_filenames)
my_scenario.write("build2050")
my_scenario.summary

Attributes:

Name Type Description
base_scenario dict

dictionary representation of a scenario

road_net RoadwayNetwork | None

instance of RoadwayNetwork for the scenario

transit_net TransitNetwork | None

instance of TransitNetwork for the scenario

project_cards dict[str, ProjectCard]

Mapping[ProjectCard.name,ProjectCard] Storage of all project cards by name.

queued_projects

Projects which are “shovel ready” - have had pre-requisits checked and done any required re-ordering. Similar to a git staging, project cards aren’t recognized in this collecton once they are moved to applied.

applied_projects list[str]

list of project names that have been applied

projects

list of all projects either planned, queued, or applied

prerequisites dict[str, list[str]]

dictionary storing prerequiste info as projectA: [prereqs-for-projectA]

corequisites dict[str, list[str]]

dictionary storing corequisite info asprojectA: [coreqs-for-projectA]

conflicts dict[str, list[str]]

dictionary storing conflict info as projectA: [conflicts-for-projectA]

config

WranglerConfig instance.

Source code in network_wrangler/scenario.py
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
class Scenario:
    """Holds information about a scenario.

    Typical usage example:

    ```python
    my_base_year_scenario = {
        "road_net": load_roadway(
            links_file=STPAUL_LINK_FILE,
            nodes_file=STPAUL_NODE_FILE,
            shapes_file=STPAUL_SHAPE_FILE,
        ),
        "transit_net": load_transit(STPAUL_DIR),
    }

    # create a future baseline scenario from base by searching for all cards in dir w/ baseline tag
    project_card_directory = Path(STPAUL_DIR) / "project_cards"
    my_scenario = create_scenario(
        base_scenario=my_base_year_scenario,
        card_search_dir=project_card_directory,
        filter_tags=["baseline2050"],
    )

    # check project card queue and then apply the projects
    my_scenario.queued_projects
    my_scenario.apply_all_projects()

    # check applied projects, write it out, and create a summary report.
    my_scenario.applied_projects
    my_scenario.write("baseline")
    my_scenario.summary

    # Add some projects to create a build scenario based on a list of files.
    build_card_filenames = [
        "3_multiple_roadway_attribute_change.yml",
        "road.prop_changes.segment.yml",
        "4_simple_managed_lane.yml",
    ]
    my_scenario.add_projects_from_files(build_card_filenames)
    my_scenario.write("build2050")
    my_scenario.summary
    ```

    Attributes:
        base_scenario: dictionary representation of a scenario
        road_net: instance of RoadwayNetwork for the scenario
        transit_net: instance of TransitNetwork for the scenario
        project_cards: Mapping[ProjectCard.name,ProjectCard] Storage of all project cards by name.
        queued_projects: Projects which are "shovel ready" - have had pre-requisits checked and
            done any required re-ordering. Similar to a git staging, project cards aren't
            recognized in this collecton once they are moved to applied.
        applied_projects: list of project names that have been applied
        projects: list of all projects either planned, queued, or applied
        prerequisites:  dictionary storing prerequiste info as `projectA: [prereqs-for-projectA]`
        corequisites:  dictionary storing corequisite info as`projectA: [coreqs-for-projectA]`
        conflicts: dictionary storing conflict info as `projectA: [conflicts-for-projectA]`
        config: WranglerConfig instance.
    """

    def __init__(
        self,
        base_scenario: Scenario | dict,
        project_card_list: list[ProjectCard] | None = None,
        config: WranglerConfig | dict | Path | list[Path] | None = None,
        name: str = "",
    ):
        """Constructor.

        Args:
            base_scenario: A base scenario object to base this isntance off of, or a dict which
                describes the scenario attributes including applied projects and respective
                conflicts. `{"applied_projects": [],"conflicts":{...}}`
            project_card_list: Optional list of ProjectCard instances to add to planned projects.
                Defaults to None.
            config: WranglerConfig instance or a dictionary of configuration settings or a path to
                one or more configuration files. Configurations that are not explicity set will
                default to the values in the default configuration in
                `/configs/wrangler/default.yml`.
            name: Optional name for the scenario.
        """
        WranglerLogger.info("Creating Scenario")
        self.config = load_wrangler_config(config)

        if project_card_list is None:
            project_card_list = []

        if isinstance(base_scenario, Scenario):
            base_scenario = base_scenario.__dict__

        self.base_scenario: dict = extract_base_scenario_metadata(base_scenario)

        if not set(BASE_SCENARIO_SUGGESTED_PROPS) <= set(base_scenario.keys()):
            WranglerLogger.warning(
                f"Base_scenario doesn't contain {BASE_SCENARIO_SUGGESTED_PROPS}"
            )
        self.name: str = name
        # if the base scenario had roadway or transit networks, use them as the basis.
        self.road_net: RoadwayNetwork | None = copy.deepcopy(base_scenario.pop("road_net", None))

        self.transit_net: TransitNetwork | None = copy.deepcopy(
            base_scenario.pop("transit_net", None)
        )
        if self.road_net and self.transit_net:
            self.transit_net.road_net = self.road_net

        # Set configs for networks to be the same as scenario.
        if isinstance(self.road_net, RoadwayNetwork):
            self.road_net.config = self.config
        if isinstance(self.transit_net, TransitNetwork):
            self.transit_net.config = self.config

        self.project_cards: dict[str, ProjectCard] = {}
        self._planned_projects: list[str] = []
        self._queued_projects = None
        self.applied_projects: list[str] = base_scenario.pop("applied_projects", [])

        self.prerequisites: dict[str, list[str]] = base_scenario.pop("prerequisites", {})
        self.corequisites: dict[str, list[str]] = base_scenario.pop("corequisites", {})
        self.conflicts: dict[str, list[str]] = base_scenario.pop("conflicts", {})

        for p in project_card_list:
            self._add_project(p)

    @property
    def projects(self):
        """Returns a list of all projects in the scenario: applied and planned."""
        return self.applied_projects + self._planned_projects

    @property
    def queued_projects(self):
        """Returns a list version of _queued_projects queue.

        Queued projects are thos that have been planned, have all pre-requisites satisfied, and
        have been ordered based on pre-requisites.

        If no queued projects, will dynamically generate from planned projects based on
        pre-requisites and return the queue.
        """
        if not self._queued_projects:
            self._check_projects_requirements_satisfied(self._planned_projects)
            self._queued_projects = self.order_projects(self._planned_projects)
        return list(self._queued_projects)

    def __str__(self):
        """String representation of the Scenario object."""
        s = [f"{key}: {value}" for key, value in self.__dict__.items()]
        return "\n".join(s)

    def _add_dependencies(self, project_name, dependencies: dict) -> None:
        """Add dependencies from a project card to relevant scenario variables.

        Updates existing "prerequisites", "corequisites" and "conflicts".
        Lowercases everything to enable string matching.

        Args:
            project_name: name of project you are adding dependencies for.
            dependencies: Dictionary of depndencies by dependency type and list of associated
                projects.
        """
        project_name = project_name.lower()

        for d, v in dependencies.items():
            _dep = list(map(str.lower, v))
            WranglerLogger.debug(f"Adding {_dep} to {project_name} dependency table.")
            self.__dict__[d].update({project_name: _dep})

    def _add_project(
        self,
        project_card: ProjectCard,
        validate: bool = True,
        filter_tags: list[str] | None = None,
    ) -> None:
        """Adds a single ProjectCard instances to the Scenario.

        Checks that a project of same name is not already in scenario.
        If selected, will validate ProjectCard before adding.
        If provided, will only add ProjectCard if it matches at least one filter_tags.

        Resets scenario queued_projects.

        Args:
            project_card (ProjectCard): ProjectCard instance to add to scenario.
            validate (bool, optional): If True, will validate the projectcard before
                being adding it to the scenario. Defaults to True.
            filter_tags: If used, will only add the project card if
                its tags match one or more of these filter_tags. Defaults to []
                which means no tag-filtering will occur.

        """
        filter_tags = filter_tags or []
        project_name = project_card.project.lower()
        filter_tags = list(map(str.lower, filter_tags))

        if project_name in self.projects:
            msg = f"Names not unique from existing scenario projects: {project_card.project}"
            raise ProjectCardError(msg)

        if filter_tags and set(project_card.tags).isdisjoint(set(filter_tags)):
            WranglerLogger.debug(
                f"Skipping {project_name} - no overlapping tags with {filter_tags}."
            )
            return

        if validate:
            project_card.validate()

        WranglerLogger.info(f"Adding {project_name} to scenario.")
        self.project_cards[project_name] = project_card
        self._planned_projects.append(project_name)
        self._queued_projects = None
        self._add_dependencies(project_name, project_card.dependencies)

    def add_project_cards(
        self,
        project_card_list: list[ProjectCard],
        validate: bool = True,
        filter_tags: list[str] | None = None,
    ) -> None:
        """Adds a list of ProjectCard instances to the Scenario.

        Checks that a project of same name is not already in scenario.
        If selected, will validate ProjectCard before adding.
        If provided, will only add ProjectCard if it matches at least one filter_tags.

        Args:
            project_card_list: List of ProjectCard instances to add to
                scenario.
            validate (bool, optional): If True, will require each ProjectCard is validated before
                being added to scenario. Defaults to True.
            filter_tags: If used, will filter ProjectCard instances
                and only add those whose tags match one or more of these filter_tags.
                Defaults to [] - which means no tag-filtering will occur.
        """
        filter_tags = filter_tags or []
        for p in project_card_list:
            self._add_project(p, validate=validate, filter_tags=filter_tags)

    def _check_projects_requirements_satisfied(self, project_list: list[str]):
        """Checks all requirements are satisified to apply this specific set of projects.

        Including:
        1. has an associaed project card
        2. is in scenario's planned projects
        3. pre-requisites satisfied
        4. co-requisies satisfied by applied or co-applied projects
        5. no conflicing applied or co-applied projects

        Args:
            project_list: list of projects to check requirements for.
        """
        self._check_projects_planned(project_list)
        self._check_projects_have_project_cards(project_list)
        self._check_projects_prerequisites(project_list)
        self._check_projects_corequisites(project_list)
        self._check_projects_conflicts(project_list)

    def _check_projects_planned(self, project_names: list[str]) -> None:
        """Checks that a list of projects are in the scenario's planned projects."""
        _missing_ps = [p for p in project_names if p not in self._planned_projects]
        if _missing_ps:
            msg = f"Projects are not in planned projects: \n {_missing_ps}. \
                Add them by using add_project_cards()."
            WranglerLogger.debug(msg)
            raise ValueError(msg)

    def _check_projects_have_project_cards(self, project_list: list[str]) -> bool:
        """Checks that a list of projects has an associated project card in the scenario."""
        _missing = [p for p in project_list if p not in self.project_cards]
        if _missing:
            WranglerLogger.error(
                f"Projects referenced which are missing project cards: {_missing}"
            )
            return False
        return True

    def _check_projects_prerequisites(self, project_names: list[str]) -> None:
        """Check a list of projects' pre-requisites have been or will be applied to scenario."""
        if set(project_names).isdisjoint(set(self.prerequisites.keys())):
            return
        _prereqs = []
        for p in project_names:
            _prereqs += self.prerequisites.get(p, [])
        _projects_applied = self.applied_projects + project_names
        _missing = list(set(_prereqs) - set(_projects_applied))
        if _missing:
            WranglerLogger.debug(
                f"project_names: {project_names}\nprojects_have_or_will_be_applied: \
                    {_projects_applied}\nmissing: {_missing}"
            )
            msg = f"Missing {len(_missing)} pre-requisites."
            raise ScenarioPrerequisiteError(msg)

    def _check_projects_corequisites(self, project_names: list[str]) -> None:
        """Check a list of projects' co-requisites have been or will be applied to scenario."""
        if set(project_names).isdisjoint(set(self.corequisites.keys())):
            return
        _coreqs = []
        for p in project_names:
            _coreqs += self.corequisites.get(p, [])
        _projects_applied = self.applied_projects + project_names
        _missing = list(set(_coreqs) - set(_projects_applied))
        if _missing:
            WranglerLogger.debug(
                f"project_names: {project_names}\nprojects_have_or_will_be_applied: \
                    {_projects_applied}\nmissing: {_missing}"
            )
            msg = f"Missing {len(_missing)} corequisites."
            raise ScenarioCorequisiteError(msg)

    def _check_projects_conflicts(self, project_names: list[str]) -> None:
        """Checks that list of projects' conflicts have not been or will be applied to scenario."""
        # WranglerLogger.debug("Checking Conflicts...")
        projects_to_check = project_names + self.applied_projects
        # WranglerLogger.debug(f"\nprojects_to_check:{projects_to_check}\nprojects_with_conflicts:{set(self.conflicts.keys())}")
        if set(projects_to_check).isdisjoint(set(self.conflicts.keys())):
            # WranglerLogger.debug("Projects have no conflicts to check")
            return
        _conflicts = []
        for p in project_names:
            _conflicts += self.conflicts.get(p, [])
        _conflict_problems = [p for p in _conflicts if p in projects_to_check]
        if _conflict_problems:
            WranglerLogger.warning(f"Conflict Problems: \n{_conflict_problems}")
            _conf_dict = {
                k: v
                for k, v in self.conflicts.items()
                if k in projects_to_check and not set(v).isdisjoint(set(_conflict_problems))
            }
            WranglerLogger.debug(f"Problematic Conflicts: \n{_conf_dict}")
            msg = f"Found {len(_conflict_problems)} conflicts: {_conflict_problems}"
            raise ScenarioConflictError(msg)

    def order_projects(self, project_list: list[str]) -> deque:
        """Orders a list of projects based on moving up pre-requisites into a deque.

        Args:
            project_list: list of projects to order

        Returns: deque for applying projects.
        """
        project_list = [p.lower() for p in project_list]
        assert self._check_projects_have_project_cards(project_list)

        # build prereq (adjacency) list for topological sort
        adjacency_list: dict[str, list] = defaultdict(list)
        visited_list: dict[str, bool] = defaultdict(bool)

        for project in project_list:
            visited_list[project] = False
            if not self.prerequisites.get(project):
                continue
            for prereq in self.prerequisites[project]:
                # this will always be true, else would have been flagged in missing \
                # prerequsite check, but just in case
                if prereq.lower() in project_list:
                    if adjacency_list.get(prereq.lower()):
                        adjacency_list[prereq.lower()].append(project)
                    else:
                        adjacency_list[prereq.lower()] = [project]

        # sorted_project_names is topological sorted project card names (based on prerequsiite)
        _ordered_projects = topological_sort(
            adjacency_list=adjacency_list, visited_list=visited_list
        )

        if set(_ordered_projects) != set(project_list):
            _missing = list(set(project_list) - set(_ordered_projects))
            msg = f"Project sort resulted in missing projects: {_missing}"
            raise ValueError(msg)

        project_deque = deque(_ordered_projects)

        WranglerLogger.debug(f"Ordered Projects: \n{project_deque}")

        return project_deque

    def apply_all_projects(self):
        """Applies all planned projects in the queue."""
        # Call this to make sure projects are appropriately queued in hidden variable.
        self.queued_projects  # noqa: B018

        # Use hidden variable.
        while self._queued_projects:
            self._apply_project(self._queued_projects.popleft())

        # set this so it will trigger re-queuing any more projects.
        self._queued_projects = None

    def _apply_change(self, change: ProjectCard | SubProject) -> None:
        """Applies a specific change specified in a project card.

        Change type must be in at least one of:
        - ROADWAY_CARD_TYPES
        - TRANSIT_CARD_TYPES

        Args:
            change: a project card or subproject card
        """
        if change.change_type in ROADWAY_CARD_TYPES:
            if not self.road_net:
                msg = "Missing Roadway Network"
                raise ValueError(msg)
            if change.change_type in SECONDARY_TRANSIT_CARD_TYPES and self.transit_net:
                self.road_net.apply(change, transit_net=self.transit_net)
            else:
                self.road_net.apply(change)
        if change.change_type in TRANSIT_CARD_TYPES:
            if not self.transit_net:
                msg = "Missing Transit Network"
                raise ValueError(msg)
            self.transit_net.apply(change)

        if change.change_type not in ROADWAY_CARD_TYPES + TRANSIT_CARD_TYPES:
            msg = f"Project {change.project}: Don't understand project cat: {change.change_type}"
            raise ProjectCardError(msg)

    def _apply_project(self, project_name: str) -> None:
        """Applies project card to scenario.

        If a list of changes is specified in referenced project card, iterates through each change.

        Args:
            project_name (str): name of project to be applied.
        """
        project_name = project_name.lower()

        WranglerLogger.info(
            f"Applying {project_name} from file:\
                            {self.project_cards[project_name].file}"
        )

        p = self.project_cards[project_name]
        WranglerLogger.debug(f"types: {p.change_types}")
        WranglerLogger.debug(f"type: {p.change_type}")
        if p._sub_projects:
            for sp in p._sub_projects:
                WranglerLogger.debug(f"- applying subproject: {sp.change_type}")
                self._apply_change(sp)

        else:
            self._apply_change(p)

        self._planned_projects.remove(project_name)
        self.applied_projects.append(project_name)

    def apply_projects(self, project_list: list[str]):
        """Applies a specific list of projects from the planned project queue.

        Will order the list of projects based on pre-requisites.

        NOTE: does not check co-requisites b/c that isn't possible when applying a single project.

        Args:
            project_list: List of projects to be applied. All need to be in the planned project
                queue.
        """
        project_list = [p.lower() for p in project_list]

        self._check_projects_requirements_satisfied(project_list)
        ordered_project_queue = self.order_projects(project_list)

        while ordered_project_queue:
            self._apply_project(ordered_project_queue.popleft())

        # Set so that when called again it will retrigger queueing from planned projects.
        self._ordered_projects = None

    def write(
        self,
        path: Path,
        name: str,
        overwrite: bool = True,
        roadway_write: bool = True,
        transit_write: bool = True,
        projects_write: bool = True,
        roadway_convert_complex_link_properties_to_single_field: bool = False,
        roadway_out_dir: Path | None = None,
        roadway_prefix: str | None = None,
        roadway_file_format: RoadwayFileTypes = "parquet",
        roadway_true_shape: bool = False,
        transit_out_dir: Path | None = None,
        transit_prefix: str | None = None,
        transit_file_format: TransitFileTypes = "txt",
        projects_out_dir: Path | None = None,
    ) -> Path:
        """Writes scenario networks and summary to disk and returns path to scenario file.

        Args:
            path: Path to write scenario networks and scenario summary to.
            name: Name to use.
            overwrite: If True, will overwrite the files if they already exist.
            roadway_write: If True, will write out the roadway network.
            transit_write: If True, will write out the transit network.
            projects_write: If True, will write out the project cards.
            roadway_convert_complex_link_properties_to_single_field: If True, will convert complex
                link properties to a single field.
            roadway_out_dir: Path to write the roadway network files to.
            roadway_prefix: Prefix to add to the file name.
            roadway_file_format: File format to write the roadway network to
            roadway_true_shape: If True, will write the true shape of the roadway network
            transit_out_dir: Path to write the transit network files to.
            transit_prefix: Prefix to add to the file name.
            transit_file_format: File format to write the transit network to
            projects_out_dir: Path to write the project cards to.
        """
        path = Path(path)
        path.mkdir(parents=True, exist_ok=True)

        if self.road_net and roadway_write:
            if roadway_out_dir is None:
                roadway_out_dir = path / "roadway"
            roadway_out_dir.mkdir(parents=True, exist_ok=True)

            write_roadway(
                net=self.road_net,
                out_dir=roadway_out_dir,
                prefix=roadway_prefix or name,
                convert_complex_link_properties_to_single_field=roadway_convert_complex_link_properties_to_single_field,
                file_format=roadway_file_format,
                true_shape=roadway_true_shape,
                overwrite=overwrite,
            )
        if self.transit_net and transit_write:
            if transit_out_dir is None:
                transit_out_dir = path / "transit"
            transit_out_dir.mkdir(parents=True, exist_ok=True)
            write_transit(
                self.transit_net,
                out_dir=transit_out_dir,
                prefix=transit_prefix or name,
                file_format=transit_file_format,
                overwrite=overwrite,
            )
        if projects_write:
            if projects_out_dir is None:
                projects_out_dir = path / "projects"
            write_applied_projects(
                self,
                out_dir=projects_out_dir,
                overwrite=overwrite,
            )

        scenario_data = self.summary
        if transit_write:
            scenario_data["transit"] = {
                "dir": str(transit_out_dir),
                "file_format": transit_file_format,
            }
        if roadway_write:
            scenario_data["roadway"] = {
                "dir": str(roadway_out_dir),
                "file_format": roadway_file_format,
            }
        if projects_write:
            scenario_data["project_cards"] = {"dir": str(projects_out_dir)}
        scenario_file_path = Path(path) / f"{name}_scenario.yml"
        with scenario_file_path.open("w") as f:
            yaml.dump(scenario_data, f, default_flow_style=False, allow_unicode=True)
        return scenario_file_path

    @property
    def summary(self) -> dict:
        """A high level summary of the created scenario and public attributes."""
        skip = ["road_net", "base_scenario", "transit_net", "project_cards", "config"]
        summary_dict = {
            k: v for k, v in self.__dict__.items() if not k.startswith("_") and k not in skip
        }
        summary_dict["config"] = self.config.to_dict()

        """
        # Handle nested dictionary for "base_scenario"
        skip_base = ["project_cards"]
        if "base_scenario" in self.__dict__:
            base_summary_dict = {
                k: v
                for k, v in self.base_scenario.items()
                if not k.startswith("_") and k not in skip_base
            }
            summary_dict["base_scenario"] = base_summary_dict
        """

        return summary_dict

network_wrangler.scenario.Scenario.projects property

projects

Returns a list of all projects in the scenario: applied and planned.

network_wrangler.scenario.Scenario.queued_projects property

queued_projects

Returns a list version of _queued_projects queue.

Queued projects are thos that have been planned, have all pre-requisites satisfied, and have been ordered based on pre-requisites.

If no queued projects, will dynamically generate from planned projects based on pre-requisites and return the queue.

network_wrangler.scenario.Scenario.summary property

summary

A high level summary of the created scenario and public attributes.

network_wrangler.scenario.Scenario.__init__

__init__(
    base_scenario,
    project_card_list=None,
    config=None,
    name="",
)

Constructor.

Parameters:

Name Type Description Default
base_scenario Scenario | dict

A base scenario object to base this isntance off of, or a dict which describes the scenario attributes including applied projects and respective conflicts. {"applied_projects": [],"conflicts":{...}}

required
project_card_list list[ProjectCard] | None

Optional list of ProjectCard instances to add to planned projects. Defaults to None.

None
config WranglerConfig | dict | Path | list[Path] | None

WranglerConfig instance or a dictionary of configuration settings or a path to one or more configuration files. Configurations that are not explicity set will default to the values in the default configuration in /configs/wrangler/default.yml.

None
name str

Optional name for the scenario.

''
Source code in network_wrangler/scenario.py
def __init__(
    self,
    base_scenario: Scenario | dict,
    project_card_list: list[ProjectCard] | None = None,
    config: WranglerConfig | dict | Path | list[Path] | None = None,
    name: str = "",
):
    """Constructor.

    Args:
        base_scenario: A base scenario object to base this isntance off of, or a dict which
            describes the scenario attributes including applied projects and respective
            conflicts. `{"applied_projects": [],"conflicts":{...}}`
        project_card_list: Optional list of ProjectCard instances to add to planned projects.
            Defaults to None.
        config: WranglerConfig instance or a dictionary of configuration settings or a path to
            one or more configuration files. Configurations that are not explicity set will
            default to the values in the default configuration in
            `/configs/wrangler/default.yml`.
        name: Optional name for the scenario.
    """
    WranglerLogger.info("Creating Scenario")
    self.config = load_wrangler_config(config)

    if project_card_list is None:
        project_card_list = []

    if isinstance(base_scenario, Scenario):
        base_scenario = base_scenario.__dict__

    self.base_scenario: dict = extract_base_scenario_metadata(base_scenario)

    if not set(BASE_SCENARIO_SUGGESTED_PROPS) <= set(base_scenario.keys()):
        WranglerLogger.warning(
            f"Base_scenario doesn't contain {BASE_SCENARIO_SUGGESTED_PROPS}"
        )
    self.name: str = name
    # if the base scenario had roadway or transit networks, use them as the basis.
    self.road_net: RoadwayNetwork | None = copy.deepcopy(base_scenario.pop("road_net", None))

    self.transit_net: TransitNetwork | None = copy.deepcopy(
        base_scenario.pop("transit_net", None)
    )
    if self.road_net and self.transit_net:
        self.transit_net.road_net = self.road_net

    # Set configs for networks to be the same as scenario.
    if isinstance(self.road_net, RoadwayNetwork):
        self.road_net.config = self.config
    if isinstance(self.transit_net, TransitNetwork):
        self.transit_net.config = self.config

    self.project_cards: dict[str, ProjectCard] = {}
    self._planned_projects: list[str] = []
    self._queued_projects = None
    self.applied_projects: list[str] = base_scenario.pop("applied_projects", [])

    self.prerequisites: dict[str, list[str]] = base_scenario.pop("prerequisites", {})
    self.corequisites: dict[str, list[str]] = base_scenario.pop("corequisites", {})
    self.conflicts: dict[str, list[str]] = base_scenario.pop("conflicts", {})

    for p in project_card_list:
        self._add_project(p)

network_wrangler.scenario.Scenario.__str__

__str__()

String representation of the Scenario object.

Source code in network_wrangler/scenario.py
def __str__(self):
    """String representation of the Scenario object."""
    s = [f"{key}: {value}" for key, value in self.__dict__.items()]
    return "\n".join(s)

network_wrangler.scenario.Scenario.add_project_cards

add_project_cards(
    project_card_list, validate=True, filter_tags=None
)

Adds a list of ProjectCard instances to the Scenario.

Checks that a project of same name is not already in scenario. If selected, will validate ProjectCard before adding. If provided, will only add ProjectCard if it matches at least one filter_tags.

Parameters:

Name Type Description Default
project_card_list list[ProjectCard]

List of ProjectCard instances to add to scenario.

required
validate bool

If True, will require each ProjectCard is validated before being added to scenario. Defaults to True.

True
filter_tags list[str] | None

If used, will filter ProjectCard instances and only add those whose tags match one or more of these filter_tags. Defaults to [] - which means no tag-filtering will occur.

None
Source code in network_wrangler/scenario.py
def add_project_cards(
    self,
    project_card_list: list[ProjectCard],
    validate: bool = True,
    filter_tags: list[str] | None = None,
) -> None:
    """Adds a list of ProjectCard instances to the Scenario.

    Checks that a project of same name is not already in scenario.
    If selected, will validate ProjectCard before adding.
    If provided, will only add ProjectCard if it matches at least one filter_tags.

    Args:
        project_card_list: List of ProjectCard instances to add to
            scenario.
        validate (bool, optional): If True, will require each ProjectCard is validated before
            being added to scenario. Defaults to True.
        filter_tags: If used, will filter ProjectCard instances
            and only add those whose tags match one or more of these filter_tags.
            Defaults to [] - which means no tag-filtering will occur.
    """
    filter_tags = filter_tags or []
    for p in project_card_list:
        self._add_project(p, validate=validate, filter_tags=filter_tags)

network_wrangler.scenario.Scenario.apply_all_projects

apply_all_projects()

Applies all planned projects in the queue.

Source code in network_wrangler/scenario.py
def apply_all_projects(self):
    """Applies all planned projects in the queue."""
    # Call this to make sure projects are appropriately queued in hidden variable.
    self.queued_projects  # noqa: B018

    # Use hidden variable.
    while self._queued_projects:
        self._apply_project(self._queued_projects.popleft())

    # set this so it will trigger re-queuing any more projects.
    self._queued_projects = None

network_wrangler.scenario.Scenario.apply_projects

apply_projects(project_list)

Applies a specific list of projects from the planned project queue.

Will order the list of projects based on pre-requisites.

NOTE: does not check co-requisites b/c that isn’t possible when applying a single project.

Parameters:

Name Type Description Default
project_list list[str]

List of projects to be applied. All need to be in the planned project queue.

required
Source code in network_wrangler/scenario.py
def apply_projects(self, project_list: list[str]):
    """Applies a specific list of projects from the planned project queue.

    Will order the list of projects based on pre-requisites.

    NOTE: does not check co-requisites b/c that isn't possible when applying a single project.

    Args:
        project_list: List of projects to be applied. All need to be in the planned project
            queue.
    """
    project_list = [p.lower() for p in project_list]

    self._check_projects_requirements_satisfied(project_list)
    ordered_project_queue = self.order_projects(project_list)

    while ordered_project_queue:
        self._apply_project(ordered_project_queue.popleft())

    # Set so that when called again it will retrigger queueing from planned projects.
    self._ordered_projects = None

network_wrangler.scenario.Scenario.order_projects

order_projects(project_list)

Orders a list of projects based on moving up pre-requisites into a deque.

Parameters:

Name Type Description Default
project_list list[str]

list of projects to order

required
Source code in network_wrangler/scenario.py
def order_projects(self, project_list: list[str]) -> deque:
    """Orders a list of projects based on moving up pre-requisites into a deque.

    Args:
        project_list: list of projects to order

    Returns: deque for applying projects.
    """
    project_list = [p.lower() for p in project_list]
    assert self._check_projects_have_project_cards(project_list)

    # build prereq (adjacency) list for topological sort
    adjacency_list: dict[str, list] = defaultdict(list)
    visited_list: dict[str, bool] = defaultdict(bool)

    for project in project_list:
        visited_list[project] = False
        if not self.prerequisites.get(project):
            continue
        for prereq in self.prerequisites[project]:
            # this will always be true, else would have been flagged in missing \
            # prerequsite check, but just in case
            if prereq.lower() in project_list:
                if adjacency_list.get(prereq.lower()):
                    adjacency_list[prereq.lower()].append(project)
                else:
                    adjacency_list[prereq.lower()] = [project]

    # sorted_project_names is topological sorted project card names (based on prerequsiite)
    _ordered_projects = topological_sort(
        adjacency_list=adjacency_list, visited_list=visited_list
    )

    if set(_ordered_projects) != set(project_list):
        _missing = list(set(project_list) - set(_ordered_projects))
        msg = f"Project sort resulted in missing projects: {_missing}"
        raise ValueError(msg)

    project_deque = deque(_ordered_projects)

    WranglerLogger.debug(f"Ordered Projects: \n{project_deque}")

    return project_deque

network_wrangler.scenario.Scenario.write

write(
    path,
    name,
    overwrite=True,
    roadway_write=True,
    transit_write=True,
    projects_write=True,
    roadway_convert_complex_link_properties_to_single_field=False,
    roadway_out_dir=None,
    roadway_prefix=None,
    roadway_file_format="parquet",
    roadway_true_shape=False,
    transit_out_dir=None,
    transit_prefix=None,
    transit_file_format="txt",
    projects_out_dir=None,
)

Writes scenario networks and summary to disk and returns path to scenario file.

Parameters:

Name Type Description Default
path Path

Path to write scenario networks and scenario summary to.

required
name str

Name to use.

required
overwrite bool

If True, will overwrite the files if they already exist.

True
roadway_write bool

If True, will write out the roadway network.

True
transit_write bool

If True, will write out the transit network.

True
projects_write bool

If True, will write out the project cards.

True
roadway_convert_complex_link_properties_to_single_field bool

If True, will convert complex link properties to a single field.

False
roadway_out_dir Path | None

Path to write the roadway network files to.

None
roadway_prefix str | None

Prefix to add to the file name.

None
roadway_file_format RoadwayFileTypes

File format to write the roadway network to

'parquet'
roadway_true_shape bool

If True, will write the true shape of the roadway network

False
transit_out_dir Path | None

Path to write the transit network files to.

None
transit_prefix str | None

Prefix to add to the file name.

None
transit_file_format TransitFileTypes

File format to write the transit network to

'txt'
projects_out_dir Path | None

Path to write the project cards to.

None
Source code in network_wrangler/scenario.py
def write(
    self,
    path: Path,
    name: str,
    overwrite: bool = True,
    roadway_write: bool = True,
    transit_write: bool = True,
    projects_write: bool = True,
    roadway_convert_complex_link_properties_to_single_field: bool = False,
    roadway_out_dir: Path | None = None,
    roadway_prefix: str | None = None,
    roadway_file_format: RoadwayFileTypes = "parquet",
    roadway_true_shape: bool = False,
    transit_out_dir: Path | None = None,
    transit_prefix: str | None = None,
    transit_file_format: TransitFileTypes = "txt",
    projects_out_dir: Path | None = None,
) -> Path:
    """Writes scenario networks and summary to disk and returns path to scenario file.

    Args:
        path: Path to write scenario networks and scenario summary to.
        name: Name to use.
        overwrite: If True, will overwrite the files if they already exist.
        roadway_write: If True, will write out the roadway network.
        transit_write: If True, will write out the transit network.
        projects_write: If True, will write out the project cards.
        roadway_convert_complex_link_properties_to_single_field: If True, will convert complex
            link properties to a single field.
        roadway_out_dir: Path to write the roadway network files to.
        roadway_prefix: Prefix to add to the file name.
        roadway_file_format: File format to write the roadway network to
        roadway_true_shape: If True, will write the true shape of the roadway network
        transit_out_dir: Path to write the transit network files to.
        transit_prefix: Prefix to add to the file name.
        transit_file_format: File format to write the transit network to
        projects_out_dir: Path to write the project cards to.
    """
    path = Path(path)
    path.mkdir(parents=True, exist_ok=True)

    if self.road_net and roadway_write:
        if roadway_out_dir is None:
            roadway_out_dir = path / "roadway"
        roadway_out_dir.mkdir(parents=True, exist_ok=True)

        write_roadway(
            net=self.road_net,
            out_dir=roadway_out_dir,
            prefix=roadway_prefix or name,
            convert_complex_link_properties_to_single_field=roadway_convert_complex_link_properties_to_single_field,
            file_format=roadway_file_format,
            true_shape=roadway_true_shape,
            overwrite=overwrite,
        )
    if self.transit_net and transit_write:
        if transit_out_dir is None:
            transit_out_dir = path / "transit"
        transit_out_dir.mkdir(parents=True, exist_ok=True)
        write_transit(
            self.transit_net,
            out_dir=transit_out_dir,
            prefix=transit_prefix or name,
            file_format=transit_file_format,
            overwrite=overwrite,
        )
    if projects_write:
        if projects_out_dir is None:
            projects_out_dir = path / "projects"
        write_applied_projects(
            self,
            out_dir=projects_out_dir,
            overwrite=overwrite,
        )

    scenario_data = self.summary
    if transit_write:
        scenario_data["transit"] = {
            "dir": str(transit_out_dir),
            "file_format": transit_file_format,
        }
    if roadway_write:
        scenario_data["roadway"] = {
            "dir": str(roadway_out_dir),
            "file_format": roadway_file_format,
        }
    if projects_write:
        scenario_data["project_cards"] = {"dir": str(projects_out_dir)}
    scenario_file_path = Path(path) / f"{name}_scenario.yml"
    with scenario_file_path.open("w") as f:
        yaml.dump(scenario_data, f, default_flow_style=False, allow_unicode=True)
    return scenario_file_path

network_wrangler.scenario.build_scenario_from_config

build_scenario_from_config(scenario_config)

Builds a scenario from a dictionary configuration.

Parameters:

Name Type Description Default
scenario_config Path | list[Path] | ScenarioConfig | dict

Path to a configuration file, list of paths, or a dictionary of configuration.

required
Source code in network_wrangler/scenario.py
def build_scenario_from_config(
    scenario_config: Path | list[Path] | ScenarioConfig | dict,
) -> Scenario:
    """Builds a scenario from a dictionary configuration.

    Args:
        scenario_config: Path to a configuration file, list of paths, or a dictionary of
            configuration.
    """
    WranglerLogger.info(f"Building Scenario from Configuration: {scenario_config}")
    scenario_config = load_scenario_config(scenario_config)
    WranglerLogger.debug(f"{pprint.pformat(scenario_config)}")

    base_scenario = create_base_scenario(
        **scenario_config.base_scenario.to_dict(), config=scenario_config.wrangler_config
    )

    my_scenario = create_scenario(
        base_scenario=base_scenario,
        config=scenario_config.wrangler_config,
        **scenario_config.projects.to_dict(),
    )

    my_scenario.apply_all_projects()

    write_args = _scenario_output_config_to_scenario_write(scenario_config.output_scenario)
    my_scenario.write(**write_args, name=scenario_config.name)
    return my_scenario

network_wrangler.scenario.create_base_scenario

create_base_scenario(
    roadway=None,
    transit=None,
    applied_projects=None,
    conflicts=None,
    config=DefaultConfig,
)

Creates a base scenario dictionary from roadway and transit network files.

Parameters:

Name Type Description Default
roadway dict | None

kwargs for load_roadway_from_dir

None
transit dict | None

kwargs for load_transit from dir

None
applied_projects list | None

list of projects that have been applied to the base scenario.

None
conflicts dict | None

dictionary of conflicts that have been identified in the base scenario. Takes the format of {"projectA": ["projectB", "projectC"]} showing that projectA, which has been applied, conflicts with projectB and projectC and so they shouldn’t be applied in the future.

None
config WranglerConfig

WranglerConfig instance.

DefaultConfig
Source code in network_wrangler/scenario.py
def create_base_scenario(
    roadway: dict | None = None,
    transit: dict | None = None,
    applied_projects: list | None = None,
    conflicts: dict | None = None,
    config: WranglerConfig = DefaultConfig,
) -> dict:
    """Creates a base scenario dictionary from roadway and transit network files.

    Args:
        roadway: kwargs for load_roadway_from_dir
        transit: kwargs for load_transit from dir
        applied_projects: list of projects that have been applied to the base scenario.
        conflicts: dictionary of conflicts that have been identified in the base scenario.
            Takes the format of `{"projectA": ["projectB", "projectC"]}` showing that projectA,
            which has been applied, conflicts with projectB and projectC and so they shouldn't be
            applied in the future.
        config: WranglerConfig instance.
    """
    applied_projects = applied_projects or []
    conflicts = conflicts or {}
    if roadway:
        road_net = load_roadway_from_dir(**roadway, config=config)
    else:
        road_net = None
        WranglerLogger.info(
            "No roadway directory specified, base scenario will have empty roadway network."
        )

    if transit:
        transit_net = load_transit(**transit, config=config)
        if roadway:
            transit_net.road_net = road_net
    else:
        transit_net = None
        WranglerLogger.info(
            "No transit directory specified, base scenario will have empty transit network."
        )

    base_scenario = {
        "road_net": road_net,
        "transit_net": transit_net,
        "applied_projects": applied_projects,
        "conflicts": conflicts,
    }

    return base_scenario

network_wrangler.scenario.create_scenario

create_scenario(
    base_scenario=None,
    name=strftime("%Y%m%d%H%M%S"),
    project_card_list=None,
    project_card_filepath=None,
    filter_tags=None,
    config=None,
)

Creates scenario from a base scenario and adds project cards.

Project cards can be added using any/all of the following methods: 1. List of ProjectCard instances 2. List of ProjectCard files 3. Directory and optional glob search to find project card files in

Checks that a project of same name is not already in scenario. If selected, will validate ProjectCard before adding. If provided, will only add ProjectCard if it matches at least one filter_tags.

Parameters:

Name Type Description Default
base_scenario Scenario | dict | None

base Scenario scenario instances of dictionary of attributes.

None
name str

Optional name for the scenario. Defaults to current datetime.

strftime('%Y%m%d%H%M%S')
project_card_list

List of ProjectCard instances to create Scenario from. Defaults to [].

None
project_card_filepath list[Path] | Path | None

where the project card is. A single path, list of paths,

None
filter_tags list[str] | None

If used, will only add the project card if its tags match one or more of these filter_tags. Defaults to [] which means no tag-filtering will occur.

None
config dict | Path | list[Path] | WranglerConfig | None

Optional wrangler configuration file or dictionary or instance. Defaults to default config.

None
Source code in network_wrangler/scenario.py
def create_scenario(
    base_scenario: Scenario | dict | None = None,
    name: str = datetime.now().strftime("%Y%m%d%H%M%S"),
    project_card_list=None,
    project_card_filepath: list[Path] | Path | None = None,
    filter_tags: list[str] | None = None,
    config: dict | Path | list[Path] | WranglerConfig | None = None,
) -> Scenario:
    """Creates scenario from a base scenario and adds project cards.

    Project cards can be added using any/all of the following methods:
    1. List of ProjectCard instances
    2. List of ProjectCard files
    3. Directory and optional glob search to find project card files in

    Checks that a project of same name is not already in scenario.
    If selected, will validate ProjectCard before adding.
    If provided, will only add ProjectCard if it matches at least one filter_tags.

    Args:
        base_scenario: base Scenario scenario instances of dictionary of attributes.
        name: Optional name for the scenario. Defaults to current datetime.
        project_card_list: List of ProjectCard instances to create Scenario from. Defaults
            to [].
        project_card_filepath: where the project card is.  A single path, list of paths,
        a directory, or a glob pattern. Defaults to None.
        filter_tags: If used, will only add the project card if
            its tags match one or more of these filter_tags. Defaults to []
            which means no tag-filtering will occur.
        config: Optional wrangler configuration file or dictionary or instance. Defaults to
            default config.
    """
    base_scenario = base_scenario or {}
    project_card_list = project_card_list or []
    filter_tags = filter_tags or []

    scenario = Scenario(base_scenario, config=config, name=name)

    if project_card_filepath:
        project_card_list += list(
            read_cards(project_card_filepath, filter_tags=filter_tags).values()
        )

    if project_card_list:
        scenario.add_project_cards(project_card_list, filter_tags=filter_tags)

    return scenario

network_wrangler.scenario.extract_base_scenario_metadata

extract_base_scenario_metadata(base_scenario)

Extract metadata from base scenario rather than keeping all of big files.

Useful for summarizing a scenario.

Source code in network_wrangler/scenario.py
def extract_base_scenario_metadata(base_scenario: dict) -> dict:
    """Extract metadata from base scenario rather than keeping all of big files.

    Useful for summarizing a scenario.
    """
    _skip_copy = ["road_net", "transit_net", "config"]
    out_dict = {k: v for k, v in base_scenario.items() if k not in _skip_copy}
    if isinstance(base_scenario.get("road_net"), RoadwayNetwork):
        nodes_file_path = base_scenario["road_net"].nodes_df.attrs.get("source_file", None)
        if nodes_file_path is not None:
            out_dict["roadway"] = {
                "dir": str(Path(nodes_file_path).parent),
                "file_format": str(nodes_file_path.suffix).lstrip("."),
            }
    if isinstance(base_scenario.get("transit_net"), TransitNetwork):
        feed_path = base_scenario["transit_net"].feed.feed_path
        if feed_path is not None:
            out_dict["transit"] = {"dir": str(feed_path)}
    return out_dict

network_wrangler.scenario.load_scenario

load_scenario(scenario_data, name=strftime('%Y%m%d%H%M%S'))

Loads a scenario from a file written by Scenario.write() as the base scenario.

Parameters:

Name Type Description Default
scenario_data dict | Path

Scenario data as a dict or path to scenario data file

required
name str

Optional name for the scenario. Defaults to current datetime.

strftime('%Y%m%d%H%M%S')
Source code in network_wrangler/scenario.py
def load_scenario(
    scenario_data: dict | Path,
    name: str = datetime.now().strftime("%Y%m%d%H%M%S"),
) -> Scenario:
    """Loads a scenario from a file written by Scenario.write() as the base scenario.

    Args:
        scenario_data: Scenario data as a dict or path to scenario data file
        name: Optional name for the scenario. Defaults to current datetime.
    """
    if not isinstance(scenario_data, dict):
        WranglerLogger.debug(f"Loading Scenario from file: {scenario_data}")
        scenario_data = load_dict(scenario_data)
    else:
        WranglerLogger.debug("Loading Scenario from dict.")

    base_scenario_data = {
        "roadway": scenario_data.get("roadway"),
        "transit": scenario_data.get("transit"),
        "applied_projects": scenario_data.get("applied_projects", []),
        "conflicts": scenario_data.get("conflicts", {}),
    }
    base_scenario = _load_base_scenario_from_config(
        base_scenario_data, config=scenario_data["config"]
    )
    my_scenario = create_scenario(
        base_scenario=base_scenario, name=name, config=scenario_data["config"]
    )
    return my_scenario

network_wrangler.scenario.write_applied_projects

write_applied_projects(scenario, out_dir, overwrite=True)

Summarizes all projects in a scenario to folder.

Parameters:

Name Type Description Default
scenario Scenario

Scenario instance to summarize.

required
out_dir Path

Path to write the project cards.

required
overwrite bool

If True, will overwrite the files if they already exist.

True
Source code in network_wrangler/scenario.py
def write_applied_projects(scenario: Scenario, out_dir: Path, overwrite: bool = True) -> None:
    """Summarizes all projects in a scenario to folder.

    Args:
        scenario: Scenario instance to summarize.
        out_dir: Path to write the project cards.
        overwrite: If True, will overwrite the files if they already exist.
    """
    outdir = Path(out_dir)
    prep_dir(out_dir, overwrite=overwrite)

    for p in scenario.applied_projects:
        if p in scenario.project_cards:
            card = scenario.project_cards[p]
        elif p in scenario.base_scenario["project_cards"]:
            card = scenario.base_scenario["project_cards"][p]
        else:
            continue
        filename = Path(card.__dict__.get("file", f"{p}.yml")).name
        outpath = outdir / filename
        write_card(card, outpath)

Roadway Network class and functions for Network Wrangler.

Used to represent a roadway network and perform operations on it.

Usage:

from network_wrangler import load_roadway_from_dir, write_roadway

net = load_roadway_from_dir("my_dir")
net.get_selection({"links": [{"name": ["I 35E"]}]})
net.apply("my_project_card.yml")

write_roadway(net, "my_out_prefix", "my_dir", file_format="parquet")

network_wrangler.roadway.network.RoadwayNetwork

Bases: BaseModel


              flowchart TD
              network_wrangler.roadway.network.RoadwayNetwork[RoadwayNetwork]

              

              click network_wrangler.roadway.network.RoadwayNetwork href "" "network_wrangler.roadway.network.RoadwayNetwork"
            

Representation of a Roadway Network.

Typical usage example:

net = load_roadway(
    links_file=MY_LINK_FILE,
    nodes_file=MY_NODE_FILE,
    shapes_file=MY_SHAPE_FILE,
)
my_selection = {
    "link": [{"name": ["I 35E"]}],
    "A": {"osm_node_id": "961117623"},  # start searching for segments at A
    "B": {"osm_node_id": "2564047368"},
}
net.get_selection(my_selection)

my_change = [
    {
        'property': 'lanes',
        'existing': 1,
        'set': 2,
    },
    {
        'property': 'drive_access',
        'set': 0,
    },
]

my_net.apply_roadway_feature_change(
    my_net.get_selection(my_selection),
    my_change
)

    net.model_net
    net.is_network_connected(mode="drive", nodes=self.m_nodes_df, links=self.m_links_df)
    _, disconnected_nodes = net.assess_connectivity(
        mode="walk",
        ignore_end_nodes=True,
        nodes=self.m_nodes_df,
        links=self.m_links_df
    )
    write_roadway(net,filename=my_out_prefix, path=my_dir, for_model = True)

Attributes:

Name Type Description
nodes_df RoadNodesTable

dataframe of of node records.

links_df RoadLinksTable

dataframe of link records and associated properties.

shapes_df RoadShapesTable

dataframe of detailed shape records This is lazily created iff it is called because shapes files can be expensive to read.

_selections dict

dictionary of stored roadway selection objects, mapped by RoadwayLinkSelection.sel_key or RoadwayNodeSelection.sel_key in case they are made repeatedly.

network_hash str

dynamic property of the hashed value of links_df and nodes_df. Used for quickly identifying if a network has changed since various expensive operations have taken place (i.e. generating a ModelRoadwayNetwork or a network graph)

_modification_version int

counter that increments each time the network is modified. Used for efficient change detection without expensive hash computation.

model_net ModelRoadwayNetwork

referenced ModelRoadwayNetwork object which will be lazily created if None or if the network has been modified.

config WranglerConfig

wrangler configuration object

Source code in network_wrangler/roadway/network.py
  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
class RoadwayNetwork(BaseModel):
    """Representation of a Roadway Network.

    Typical usage example:

    ```py
    net = load_roadway(
        links_file=MY_LINK_FILE,
        nodes_file=MY_NODE_FILE,
        shapes_file=MY_SHAPE_FILE,
    )
    my_selection = {
        "link": [{"name": ["I 35E"]}],
        "A": {"osm_node_id": "961117623"},  # start searching for segments at A
        "B": {"osm_node_id": "2564047368"},
    }
    net.get_selection(my_selection)

    my_change = [
        {
            'property': 'lanes',
            'existing': 1,
            'set': 2,
        },
        {
            'property': 'drive_access',
            'set': 0,
        },
    ]

    my_net.apply_roadway_feature_change(
        my_net.get_selection(my_selection),
        my_change
    )

        net.model_net
        net.is_network_connected(mode="drive", nodes=self.m_nodes_df, links=self.m_links_df)
        _, disconnected_nodes = net.assess_connectivity(
            mode="walk",
            ignore_end_nodes=True,
            nodes=self.m_nodes_df,
            links=self.m_links_df
        )
        write_roadway(net,filename=my_out_prefix, path=my_dir, for_model = True)
    ```

    Attributes:
        nodes_df (RoadNodesTable): dataframe of of node records.
        links_df (RoadLinksTable): dataframe of link records and associated properties.
        shapes_df (RoadShapesTable): dataframe of detailed shape records  This is lazily
            created iff it is called because shapes files can be expensive to read.
        _selections (dict): dictionary of stored roadway selection objects, mapped by
            `RoadwayLinkSelection.sel_key` or `RoadwayNodeSelection.sel_key` in case they are
                made repeatedly.
        network_hash: dynamic property of the hashed value of links_df and nodes_df. Used for
            quickly identifying if a network has changed since various expensive operations have
            taken place (i.e. generating a ModelRoadwayNetwork or a network graph)
        _modification_version (int): counter that increments each time the network is modified.
            Used for efficient change detection without expensive hash computation.
        model_net (ModelRoadwayNetwork): referenced `ModelRoadwayNetwork` object which will be
            lazily created if None or if the network has been modified.
        config (WranglerConfig): wrangler configuration object
    """

    model_config = ConfigDict(arbitrary_types_allowed=True)

    nodes_df: pd.DataFrame
    links_df: pd.DataFrame
    _shapes_df: pd.DataFrame | None = None

    _links_file: Path | None = None
    _nodes_file: Path | None = None
    _shapes_file: Path | None = None

    config: WranglerConfig = DefaultConfig

    _model_net: Optional[ModelRoadwayNetwork] = None
    _model_net_version: int = -1  # Version when model_net was last created
    _selections: dict[str, Selections] = {}
    _modal_graphs: dict[str, dict] = defaultdict(lambda: {"graph": None, "hash": None})
    _modification_version: int = 0  # Incremented each time network is modified

    def __str__(self):
        """Return string representation of RoadwayNetwork.

        Returns:
            str: Summary string showing network statistics and dataframe contents.
        """
        my_str = f"RoadwayNetwork(nodes={len(self.nodes_df)}, links={len(self.links_df)})"
        my_str += f"\nnodes_df (type={type(self.nodes_df)}):\n{self.nodes_df}"
        my_str += f"\nlinks_df (type={type(self.links_df)}):\n{self.links_df}"
        return my_str

    @field_validator("config")
    @classmethod
    def validate_config(cls, v):
        """Validate config."""
        return load_wrangler_config(v)

    @field_validator("nodes_df", mode="before")
    @classmethod
    def validate_nodes_df(cls, v):
        """Validate nodes_df to RoadNodesTable and coerce CRS."""
        v = validate_df_to_model(v, RoadNodesTable)
        if hasattr(v, "crs") and v.crs != LAT_LON_CRS:
            WranglerLogger.warning(
                f"CRS of nodes_df ({v.crs}) doesn't match network crs {LAT_LON_CRS}. \
                    Changing to network crs."
            )
            v = v.to_crs(LAT_LON_CRS)
        return v

    @field_validator("links_df", mode="before")
    @classmethod
    def validate_links_df(cls, v):
        """Validate links_df to RoadLinksTable and coerce CRS."""
        v = validate_df_to_model(v, RoadLinksTable)
        if hasattr(v, "crs") and v.crs != LAT_LON_CRS:
            WranglerLogger.warning(
                f"CRS of links_df ({v.crs}) doesn't match network crs {LAT_LON_CRS}. \
                    Changing to network crs."
            )
            v = v.to_crs(LAT_LON_CRS)
        return v

    # TODO: This may be overkill if many edits are being made.
    @model_validator(mode="after")
    def validate_referential_integrity(self):
        """Validate that all nodes referenced in links exist in nodes table."""
        WranglerLogger.debug(
            "validate_referential_integrity(): Validating referential integrity between links and nodes"
        )
        try:
            validate_links_have_nodes(self.links_df, self.nodes_df)
        except Exception as e:
            WranglerLogger.error(f"Referential integrity validation failed: {e}")
            raise

        return self

    @property
    def shapes_df(self) -> pd.DataFrame:
        """Load and return RoadShapesTable.

        If not already loaded, will read from shapes_file and return. If shapes_file is None,
        will return an empty dataframe with the right schema. If shapes_df is already set, will
        return that.
        """
        if (self._shapes_df is None or self._shapes_df.empty) and self._shapes_file is not None:
            self._shapes_df = read_shapes(
                self._shapes_file,
                filter_to_shape_ids=self.links_df.shape_id.to_list(),
                config=self.config,
            )
        # if there is NONE, then at least create an empty dataframe with right schema
        elif self._shapes_df is None:
            self._shapes_df = empty_df_from_datamodel(RoadShapesTable)
            self._shapes_df.set_index("shape_id_idx", inplace=True)

        return self._shapes_df

    @shapes_df.setter
    def shapes_df(self, value):
        self._shapes_df = df_to_shapes_df(value, config=self.config)

    def _mark_modified(self) -> None:
        """Mark the network as modified by incrementing the modification version.

        This should be called whenever the network data is modified to ensure
        that dependent computations (selections, model networks, graphs) are
        re-evaluated. Uses a simple version counter which is much faster than
        computing hashes for change detection.
        """
        self._modification_version += 1
        WranglerLogger.debug(f"Network modified. Version: {self._modification_version}")

    @property
    def modification_version(self) -> int:
        """Return the current modification version of the network.

        This counter increments each time the network is modified and can be used
        for efficient change detection without computing expensive hashes.
        """
        return self._modification_version

    @property
    def network_hash(self) -> str:
        """Hash of the links and nodes dataframes.

        Note: This is an expensive operation. For change detection, prefer using
        modification_version which is much faster.
        """
        _value = str.encode(self.links_df.df_hash() + "-" + self.nodes_df.df_hash())

        _hash = hashlib.sha256(_value).hexdigest()
        return _hash

    @property
    def model_net(self) -> ModelRoadwayNetwork:
        """Return a ModelRoadwayNetwork object for this network.

        The model network is lazily created and cached. It is invalidated when
        the network's modification version changes.
        """
        if self._model_net is None or self._model_net_version != self._modification_version:
            self._model_net = ModelRoadwayNetwork(self)
            self._model_net_version = self._modification_version
        return self._model_net

    @property
    def summary(self) -> dict:
        """Quick summary dictionary of number of links, nodes."""
        d = {
            "links": len(self.links_df),
            "nodes": len(self.nodes_df),
        }
        return d

    @property
    def link_shapes_df(self) -> gpd.GeoDataFrame:
        """Add shape geometry to links if available.

        returns: shapes merged to links dataframe
        """
        _links_df = copy.deepcopy(self.links_df)
        link_shapes_df = _links_df.merge(
            self.shapes_df,
            left_on="shape_id",
            right_on="shape_id",
            how="left",
        )
        link_shapes_df["geometry"] = link_shapes_df["geometry_y"].combine_first(
            link_shapes_df["geometry_x"]
        )
        link_shapes_df = link_shapes_df.drop(columns=["geometry_x", "geometry_y"])
        link_shapes_df = link_shapes_df.set_geometry("geometry")
        return link_shapes_df

    def get_property_by_timespan_and_group(
        self,
        link_property: str,
        category: str | int | None = DEFAULT_CATEGORY,
        timespan: TimespanString | None = DEFAULT_TIMESPAN,
        strict_timespan_match: bool = False,
        min_overlap_minutes: int = 60,
    ) -> Any:
        """Returns a new dataframe with model_link_id and link property by category and timespan.

        Convenience method for backward compatability.

        Args:
            link_property: link property to query
            category: category to query or a list of categories. Defaults to DEFAULT_CATEGORY.
            timespan: timespan to query in the form of ["HH:MM","HH:MM"].
                Defaults to DEFAULT_TIMESPAN.
            strict_timespan_match: If True, will only return links that match the timespan exactly.
                Defaults to False.
            min_overlap_minutes: If strict_timespan_match is False, will return links that overlap
                with the timespan by at least this many minutes. Defaults to 60.
        """
        from .links.scopes import prop_for_scope

        return prop_for_scope(
            self.links_df,
            link_property,
            timespan=timespan,
            category=category,
            strict_timespan_match=strict_timespan_match,
            min_overlap_minutes=min_overlap_minutes,
        )

    def get_selection(
        self,
        selection_dict: dict | SelectFacility,
        overwrite: bool = False,
    ) -> RoadwayNodeSelection | RoadwayLinkSelection:
        """Return selection if it already exists, otherwise performs selection.

        Args:
            selection_dict (dict): SelectFacility dictionary.
            overwrite: if True, will overwrite any previously cached searches. Defaults to False.
        """
        key = _create_selection_key(selection_dict)
        if (key in self._selections) and not overwrite:
            WranglerLogger.debug(f"Using cached selection from key: {key}")
            return self._selections[key]

        if isinstance(selection_dict, SelectFacility):
            selection_data = selection_dict
        elif isinstance(selection_dict, SelectLinksDict):
            selection_data = SelectFacility(links=selection_dict)
        elif isinstance(selection_dict, SelectNodesDict):
            selection_data = SelectFacility(nodes=selection_dict)
        elif isinstance(selection_dict, dict):
            selection_data = SelectFacility(**selection_dict)
        else:
            msg = "selection_dict arg must be a dictionary or SelectFacility model."
            WranglerLogger.error(
                msg + f" Received: {selection_dict} of type {type(selection_dict)}"
            )
            raise SelectionError(msg)

        WranglerLogger.debug(f"Getting selection from key: {key}  selection_data={selection_data}")
        if "links" in selection_data.fields:
            return RoadwayLinkSelection(self, selection_dict)
        if "nodes" in selection_data.fields:
            return RoadwayNodeSelection(self, selection_dict)
        msg = "Selection data should have either 'links' or 'nodes'."
        WranglerLogger.error(msg + f" Received: {selection_dict}")
        raise SelectionError(msg)

    def modal_graph_hash(self, mode) -> str:
        """Hash of the links in order to detect a network change from when graph created.

        Note: This is an expensive operation. For internal change detection,
        get_modal_graph uses modification_version instead.
        """
        _value = str.encode(self.links_df.df_hash() + "-" + mode)
        _hash = hashlib.sha256(_value).hexdigest()

        return _hash

    def get_modal_graph(self, mode) -> MultiDiGraph:
        """Return a networkx graph of the network for a specific mode.

        Args:
            mode: mode of the network, one of `drive`,`transit`,`walk`, `bike`
        """
        from .graph import net_to_graph

        # Use modification version for efficient change detection
        current_version = (self._modification_version, mode)
        if self._modal_graphs[mode].get("version") != current_version:
            self._modal_graphs[mode]["graph"] = net_to_graph(self, mode)
            self._modal_graphs[mode]["version"] = current_version

        return self._modal_graphs[mode]["graph"]

    def apply(
        self,
        project_card: ProjectCard | dict,
        transit_net: TransitNetwork | None = None,
        **kwargs,
    ) -> RoadwayNetwork:
        """Wrapper method to apply a roadway project, returning a new RoadwayNetwork instance.

        Args:
            project_card: either a dictionary of the project card object or ProjectCard instance
            transit_net: optional transit network which will be used to if project requires as
                noted in `SECONDARY_TRANSIT_CARD_TYPES`.  If no transit network is provided, will
                skip anything related to transit network.
            **kwargs: keyword arguments to pass to project application
        """
        if not (isinstance(project_card, ProjectCard | SubProject)):
            project_card = ProjectCard(project_card)

        # project_card.validate()
        if not project_card.valid:
            msg = f"Project card {project_card.project} not valid."
            WranglerLogger.error(msg)
            raise ProjectCardError(msg)

        if project_card._sub_projects:
            for sp in project_card._sub_projects:
                WranglerLogger.debug(f"- applying subproject: {sp.change_type}")
                self._apply_change(sp, transit_net=transit_net, **kwargs)
            return self
        return self._apply_change(project_card, transit_net=transit_net, **kwargs)

    def _apply_change(
        self,
        change: ProjectCard | SubProject,
        transit_net: TransitNetwork | None = None,
    ) -> RoadwayNetwork:
        """Apply a single change: a single-project project or a sub-project."""
        if not isinstance(change, SubProject):
            WranglerLogger.info(f"Applying Project to Roadway Network: {change.project}")

        if change.change_type == "roadway_property_change":
            return apply_roadway_property_change(
                self,
                self.get_selection(change.roadway_property_change["facility"]),
                change.roadway_property_change["property_changes"],
                project_name=change.project,
            )

        if change.change_type == "roadway_addition":
            return apply_new_roadway(
                self,
                change.roadway_addition,
                project_name=change.project,
            )

        if change.change_type == "roadway_deletion":
            return apply_roadway_deletion(
                self,
                change.roadway_deletion,
                transit_net=transit_net,
            )

        if change.change_type == "pycode":
            return apply_calculated_roadway(self, change.pycode)
        WranglerLogger.error(f"Couldn't find project in: \n{change.__dict__}")
        msg = f"Invalid Project Card Category: {change.change_type}"
        raise ProjectCardError(msg)

    def links_with_link_ids(self, link_ids: list[int]) -> pd.DataFrame:
        """Return subset of links_df based on link_ids list."""
        return filter_links_to_ids(self.links_df, link_ids)

    def links_with_nodes(self, node_ids: list[int]) -> pd.DataFrame:
        """Return subset of links_df based on node_ids list."""
        return filter_links_to_node_ids(self.links_df, node_ids)

    def nodes_in_links(self) -> pd.DataFrame:
        """Returns subset of self.nodes_df that are in self.links_df."""
        return filter_nodes_to_links(self.links_df, self.nodes_df)

    def node_coords(self, model_node_id: int) -> tuple:
        """Return coordinates (x, y) of a node based on model_node_id."""
        try:
            node = self.nodes_df[self.nodes_df.model_node_id == model_node_id]
        except ValueError as err:
            msg = f"Node with model_node_id {model_node_id} not found."
            WranglerLogger.error(msg)
            raise NodeNotFoundError(msg) from err
        return node.geometry.x.values[0], node.geometry.y.values[0]

    def add_links(
        self,
        add_links_df: pd.DataFrame,
        in_crs: int = LAT_LON_CRS,
    ):
        """Validate combined links_df with LinksSchema before adding to self.links_df.

        Args:
            add_links_df: Dataframe of additional links to add.
            in_crs: crs of input data. Defaults to LAT_LON_CRS.
        """
        dupe_recs = self.links_df.model_link_id.isin(add_links_df.model_link_id)

        if dupe_recs.any():
            dupe_ids = self.links_df.loc[dupe_recs, "model_link_id"]
            WranglerLogger.error(
                f"Cannot add links with model_link_id already in network: {dupe_ids}"
            )
            msg = "Cannot add links with model_link_id already in network."
            raise LinkAddError(msg)

        if add_links_df.attrs.get("name") != "road_links":
            add_links_df = data_to_links_df(add_links_df, nodes_df=self.nodes_df, in_crs=in_crs)
        self.links_df = validate_df_to_model(
            concat_with_attr([self.links_df, add_links_df], axis=0), RoadLinksTable
        )
        self._mark_modified()

    def add_nodes(
        self,
        add_nodes_df: pd.DataFrame,
        in_crs: int = LAT_LON_CRS,
    ):
        """Validate combined nodes_df with NodesSchema before adding to self.nodes_df.

        Args:
            add_nodes_df: Dataframe of additional nodes to add.
            in_crs: crs of input data. Defaults to LAT_LON_CRS.
        """
        dupe_ids = self.nodes_df.model_node_id.isin(add_nodes_df.model_node_id)
        if dupe_ids.any():
            WranglerLogger.error(
                f"Cannot add nodes with model_node_id already in network: {dupe_ids}"
            )
            msg = "Cannot add nodes with model_node_id already in network."
            raise NodeAddError(msg)
        WranglerLogger.debug(f"add_nodes(): self.nodes_df.tail()\n{self.nodes_df.tail()}")
        WranglerLogger.debug(f"add_nodes(): add_nodes_df:\n{add_nodes_df}")

        # this will perform validation to the nodes schema
        self.nodes_df = data_to_nodes_df(
            nodes_df=concat_with_attr([self.nodes_df, add_nodes_df], axis=0), in_crs=in_crs
        )
        # Ensure attrs are preserved after validation
        self.nodes_df.attrs.update(RoadNodesAttrs)
        if self.nodes_df.attrs.get("name") != "road_nodes":
            msg = f"Expected nodes_df to have name 'road_nodes', got {self.nodes_df.attrs.get('name')}"
            raise NotNodesError(msg)
        self._mark_modified()

    def add_shapes(
        self,
        add_shapes_df: pd.DataFrame,
        in_crs: int = LAT_LON_CRS,
    ):
        """Validate combined shapes_df with RoadShapesTable efore adding to self.shapes_df.

        Args:
            add_shapes_df: Dataframe of additional shapes to add.
            in_crs: crs of input data. Defaults to LAT_LON_CRS.
        """
        if len(self.shapes_df) > 0:
            dupe_ids = self.shapes_df["shape_id"].isin(add_shapes_df["shape_id"])
            if dupe_ids.any():
                msg = "Cannot add shapes with shape_id already in network."
                WranglerLogger.error(msg + f"\nDuplicates: {dupe_ids}")
                raise ShapeAddError(msg)

        if add_shapes_df.attrs.get("name") != "road_shapes":
            add_shapes_df = df_to_shapes_df(add_shapes_df, in_crs=in_crs, config=self.config)

        WranglerLogger.debug(f"add_shapes_df: \n{add_shapes_df}")
        WranglerLogger.debug(f"self.shapes_df: \n{self.shapes_df}")

        self.shapes_df = validate_df_to_model(
            concat_with_attr([self.shapes_df, add_shapes_df], axis=0), RoadShapesTable
        )
        # Note: shapes don't affect network_hash (only links and nodes), but we invalidate
        # for consistency in case future changes include shapes in hash calculation

    def delete_links(
        self,
        selection_dict: dict | SelectLinksDict,
        clean_nodes: bool = False,
        clean_shapes: bool = False,
        transit_net: TransitNetwork | None = None,
    ):
        """Deletes links based on selection dictionary and optionally associated nodes and shapes.

        Args:
            selection_dict (SelectLinks): Dictionary describing link selections as follows:
                `all`: Optional[bool] = False. If true, will select all.
                `name`: Optional[list[str]]
                `ref`: Optional[list[str]]
                `osm_link_id`:Optional[list[str]]
                `model_link_id`: Optional[list[int]]
                `modes`: Optional[list[str]]. Defaults to "any"
                `ignore_missing`: if true, will not error when defaults to True.
                ...plus any other link property to select on top of these.
            clean_nodes (bool, optional): If True, will clean nodes uniquely associated with
                deleted links. Defaults to False.
            clean_shapes (bool, optional): If True, will clean nodes uniquely associated with
                deleted links. Defaults to False.
            transit_net (TransitNetwork, optional): If provided, will check TransitNetwork and
                warn if deletion breaks transit shapes. Defaults to None.
        """
        if not isinstance(selection_dict, SelectLinksDict):
            selection_dict = SelectLinksDict(**selection_dict)
        selection_dict = selection_dict.model_dump(exclude_none=True, by_alias=True)
        selection = self.get_selection({"links": selection_dict})
        if isinstance(selection, RoadwayNodeSelection):
            msg = "Selection should be for links, but got nodes."
            raise SelectionError(msg)
        if clean_nodes:
            node_ids_to_delete = node_ids_unique_to_link_ids(
                selection.selected_links, selection.selected_links_df, self.nodes_df
            )
            WranglerLogger.debug(
                f"Dropping nodes associated with dropped links: \n{node_ids_to_delete}"
            )
            self.nodes_df = delete_nodes_by_ids(self.nodes_df, del_node_ids=node_ids_to_delete)

        if clean_shapes:
            shape_ids_to_delete = shape_ids_unique_to_link_ids(
                selection.selected_links, selection.selected_links_df, self.shapes_df
            )
            WranglerLogger.debug(
                f"Dropping shapes associated with dropped links: \n{shape_ids_to_delete}"
            )
            self.shapes_df = delete_shapes_by_ids(
                self.shapes_df, del_shape_ids=shape_ids_to_delete
            )

        self.links_df = delete_links_by_ids(
            self.links_df,
            selection.selected_links,
            ignore_missing=selection.ignore_missing,
            transit_net=transit_net,
        )
        self._mark_modified()

    def delete_nodes(
        self,
        selection_dict: dict | SelectNodesDict,
        remove_links: bool = False,
    ) -> None:
        """Deletes nodes from roadway network. Wont delete nodes used by links in network.

        Args:
            selection_dict: dictionary of node selection criteria in the form of a SelectNodesDict.
            remove_links: if True, will remove any links that are associated with the nodes.
                If False, will only remove nodes if they are not associated with any links.
                Defaults to False.

        Raises:
            NodeDeletionError: If not ignore_missing and selected nodes to delete aren't in network
        """
        if not isinstance(selection_dict, SelectNodesDict):
            selection_dict = SelectNodesDict(**selection_dict)
        selection_dict = selection_dict.model_dump(exclude_none=True, by_alias=True)
        _selection = self.get_selection({"nodes": selection_dict})
        assert isinstance(_selection, RoadwayNodeSelection)  # for mypy
        selection: RoadwayNodeSelection = _selection
        if remove_links:
            del_node_ids = selection.selected_nodes
            link_ids = self.links_with_nodes(selection.selected_nodes).model_link_id.to_list()
            WranglerLogger.info(f"Removing {len(link_ids)} links associated with nodes.")
            self.delete_links({"model_link_id": link_ids})
        else:
            unused_node_ids = node_ids_without_links(self.nodes_df, self.links_df)
            del_node_ids = list(set(selection.selected_nodes).intersection(unused_node_ids))

        self.nodes_df = delete_nodes_by_ids(
            self.nodes_df, del_node_ids, ignore_missing=selection.ignore_missing
        )
        self._mark_modified()

    def split_link(  # noqa: PLR0912, PLR0915
        self,
        A: int,
        B: int,
        new_model_node_id: int,
        fraction: float,
        split_reverse_link: bool = True,
    ) -> None:
        """Splits a link into two links at a specified fraction of its length.

        Args:
            A: The model_node_id of the start node of the link to split.
            B: The model_node_id of the end node of the link to split.
            new_model_node_id: The model_node_id for the new node to be created at the split point.
            fraction: The fraction along the link's length where the split occurs (0 < fraction < 1).
            split_reverse_link: If True, also splits the reverse link if it exists. Defaults to True.

        Raises:
            ValueError: If the link doesn't exist, fraction is invalid, or new_model_node_id already exists.

        TODO: Use SelectionDictionary rather than A,B?
        TODO: Make new_model_node_id an optional argument because we can use
              `network_wrangler.roadway.nodes.create.generate_node_ids()`
        """
        WranglerLogger.debug(f"split_link() self.links_df.head():\n{self.links_df.head()}")
        # Find the link with given A and B nodes
        matching_links = self.links_df[(self.links_df["A"] == A) & (self.links_df["B"] == B)]

        if matching_links.empty:
            msg = f"Link from node {A} to node {B} not found in network."
            raise ValueError(msg)

        # If multiple links exist between A and B, use the first one and warn
        if len(matching_links) > 1:
            WranglerLogger.warning(
                f"Multiple links found from node {A} to {B}. Using first link with model_link_id {matching_links.index[0]}."
            )

        model_link_idx = matching_links.index[0]

        if not 0 < fraction < 1:
            msg = f"Fraction must be between 0 and 1 (exclusive), got {fraction}."
            raise ValueError(msg)

        if new_model_node_id in self.nodes_df.index:
            msg = f"Node with model_node_id {new_model_node_id} already exists in network."
            raise ValueError(msg)

        # Get the original link
        orig_link = self.links_df.loc[model_link_idx].copy()
        WranglerLogger.debug(f"Splitting link:\n{orig_link}")

        # Get the geometry to split (use shape geometry if available)
        if pd.notna(orig_link.get("shape_id")) and orig_link["shape_id"] in self.shapes_df.index:
            geometry = self.shapes_df.loc[orig_link["shape_id"], "geometry"]
        else:
            geometry = orig_link["geometry"]

        # Calculate the split point
        split_point = geometry.interpolate(fraction, normalized=True)

        # Create the new node
        # The attribute osm_node_id is not required so don't set it
        new_node_data = {
            "model_node_id": new_model_node_id,
            "X": split_point.x,
            "Y": split_point.y,
        }

        # Copy additional attributes from node A if they exist
        node_a_data = self.nodes_df.loc[self.nodes_df["model_node_id"] == orig_link["A"]].iloc[0]
        # Copy all columns except geometry, X, Y, and model_node_id
        for col in self.nodes_df.columns:
            if (
                col not in ["model_node_id", "X", "Y", "geometry", "osm_node_id"]
                and col in node_a_data.index
                and pd.notna(node_a_data[col])
            ):
                new_node_data[col] = node_a_data[col]

        # Create DataFrame for new node and add it
        new_node_df = pd.DataFrame([new_node_data])
        self.add_nodes(new_node_df)

        # Split the geometry
        # Create a small circle around the split point to ensure clean split
        split_geom = split_point.buffer(0.00001)  # Small buffer in degrees
        split_result = split(geometry, split_geom)

        if len(split_result.geoms) >= MIN_SPLIT_SEGMENTS:
            geom1 = split_result.geoms[0]
            geom2 = split_result.geoms[-1]
        else:
            # Fallback: manually create two linestrings
            coords = list(geometry.coords)
            split_idx = int(len(coords) * fraction)
            if split_idx == 0:
                split_idx = 1
            elif split_idx >= len(coords):
                split_idx = len(coords) - 1

            # Insert the split point at the right position
            coords_1 = [*coords[:split_idx], (split_point.x, split_point.y)]
            coords_2 = [(split_point.x, split_point.y), *coords[split_idx:]]

            geom1 = LineString(coords_1)
            geom2 = LineString(coords_2)

        # Create two new links
        # Find the next available model_link_ids
        max_link_id = self.links_df["model_link_id"].max()
        new_link1_id = max_link_id + 1
        new_link2_id = max_link_id + 2

        # First link: A to new node
        link1_data = orig_link.to_dict()
        link1_data["model_link_id"] = new_link1_id
        link1_data["B"] = new_model_node_id
        link1_data["geometry"] = geom1
        link1_data["distance"] = (
            orig_link.get("distance", 0) * fraction if "distance" in orig_link else None
        )
        # Clear shape_id as we're creating new geometry
        link1_data["shape_id"] = None

        # Second link: new node to B
        link2_data = orig_link.to_dict()
        link2_data["model_link_id"] = new_link2_id
        link2_data["A"] = new_model_node_id
        link2_data["geometry"] = geom2
        link2_data["distance"] = (
            orig_link.get("distance", 0) * (1 - fraction) if "distance" in orig_link else None
        )
        # Clear shape_id as we're creating new geometry
        link2_data["shape_id"] = None

        # Handle reverse link if requested
        reverse_link_ids = []
        if split_reverse_link:
            # Look for reverse link (B->A)
            reverse_link = self.links_df[
                (self.links_df["A"] == orig_link["B"]) & (self.links_df["B"] == orig_link["A"])
            ]

            if not reverse_link.empty:
                reverse_link_data = reverse_link.iloc[0].copy()

                # Get reverse geometry
                if (
                    pd.notna(reverse_link_data.get("shape_id"))
                    and reverse_link_data["shape_id"] in self.shapes_df.index
                ):
                    reverse_geometry = self.shapes_df.loc[
                        reverse_link_data["shape_id"], "geometry"
                    ]
                else:
                    reverse_geometry = reverse_link_data["geometry"]

                # Split at (1 - fraction) for reverse
                reverse_split_point = reverse_geometry.interpolate(1 - fraction, normalized=True)

                # Split the reverse geometry
                reverse_split_geom = reverse_split_point.buffer(0.00001)
                reverse_split_result = split(reverse_geometry, reverse_split_geom)

                if len(reverse_split_result.geoms) >= MIN_SPLIT_SEGMENTS:
                    reverse_geom1 = reverse_split_result.geoms[0]
                    reverse_geom2 = reverse_split_result.geoms[-1]
                else:
                    # Fallback for reverse
                    coords = list(reverse_geometry.coords)
                    split_idx = int(len(coords) * (1 - fraction))
                    if split_idx == 0:
                        split_idx = 1
                    elif split_idx >= len(coords):
                        split_idx = len(coords) - 1

                    coords_1 = [*coords[:split_idx], (split_point.x, split_point.y)]
                    coords_2 = [(split_point.x, split_point.y), *coords[split_idx:]]

                    reverse_geom1 = LineString(coords_1)
                    reverse_geom2 = LineString(coords_2)

                # Create reverse links
                new_link3_id = max_link_id + 3
                new_link4_id = max_link_id + 4

                # Reverse link 1: original B to new node
                link3_data = reverse_link_data.to_dict()
                link3_data["model_link_id"] = new_link3_id
                link3_data["B"] = new_model_node_id
                link3_data["geometry"] = reverse_geom1
                link3_data["distance"] = (
                    reverse_link_data.get("distance", 0) * (1 - fraction)
                    if "distance" in reverse_link_data
                    else None
                )
                link3_data["shape_id"] = None

                # Reverse link 2: new node to original A
                link4_data = reverse_link_data.to_dict()
                link4_data["model_link_id"] = new_link4_id
                link4_data["A"] = new_model_node_id
                link4_data["geometry"] = reverse_geom2
                link4_data["distance"] = (
                    reverse_link_data.get("distance", 0) * fraction
                    if "distance" in reverse_link_data
                    else None
                )
                link4_data["shape_id"] = None

                reverse_link_ids = [reverse_link_data["model_link_id"]]

        # Delete original links - we need to delete by the model_link_id values
        orig_model_link_id = orig_link["model_link_id"]
        links_to_delete = [orig_model_link_id, *reverse_link_ids]
        self.delete_links(
            {"model_link_id": links_to_delete, "modes": ["any"]},
            clean_nodes=False,
            clean_shapes=False,
        )

        # Add new links
        new_links_data = [link1_data, link2_data]
        if reverse_link_ids:
            new_links_data.extend([link3_data, link4_data])

        new_links_df = pd.DataFrame(new_links_data)
        self.add_links(new_links_df)

        WranglerLogger.info(
            f"Split link {orig_model_link_id} at fraction {fraction} with new node {new_model_node_id}. "
            f"Created links {new_link1_id} and {new_link2_id}."
        )

        if reverse_link_ids:
            WranglerLogger.info(
                f"Also split reverse link {reverse_link_ids[0]} creating links {new_link3_id} and {new_link4_id}."
            )

    def clean_unused_shapes(self):
        """Removes any unused shapes from network that aren't referenced by links_df."""
        from .shapes.shapes import shape_ids_without_links

        del_shape_ids = shape_ids_without_links(self.shapes_df, self.links_df)
        self.shapes_df = self.shapes_df.drop(del_shape_ids)
        # Note: shapes don't affect network_hash, but invalidate for consistency

    def clean_unused_nodes(self):
        """Removes any unused nodes from network that aren't referenced by links_df.

        NOTE: does not check if these nodes are used by transit, so use with caution.
        """
        from .nodes.nodes import node_ids_without_links

        node_ids = node_ids_without_links(self.nodes_df, self.links_df)
        self.nodes_df = self.nodes_df.drop(node_ids)
        self._mark_modified()

    def move_nodes(
        self,
        node_geometry_change_table: pd.DataFrame,
    ):
        """Moves nodes based on updated geometry along with associated links and shape geometry.

        Args:
            node_geometry_change_table: a table with model_node_id, X, Y, and CRS.
        """
        node_geometry_change_table = NodeGeometryChangeTable(node_geometry_change_table)
        node_ids = node_geometry_change_table.model_node_id.to_list()
        WranglerLogger.debug(f"Moving nodes:\n{node_geometry_change_table}")
        self.nodes_df = edit_node_geometry(self.nodes_df, node_geometry_change_table)
        WranglerLogger.debug(f"Completed edit_node_geometry()")
        self.links_df = edit_link_geometry_from_nodes(self.links_df, self.nodes_df, node_ids)
        WranglerLogger.debug(f"Completed edit_link_geometry_from_nodes()")
        self.shapes_df = edit_shape_geometry_from_nodes(
            self.shapes_df, self.links_df, self.nodes_df, node_ids
        )
        WranglerLogger.debug(f"Completed edit_shape_geometry_from_nodes()")
        self._mark_modified()

    def has_node(self, model_node_id: int) -> bool:
        """Queries if network has node based on model_node_id.

        Args:
            model_node_id: model_node_id to check for.
        """
        has_node = self.nodes_df[self.nodes_df.model_node_id].isin([model_node_id]).any()

        return has_node

    def has_link(self, ab: tuple) -> bool:
        """Returns true if network has links with AB values.

        Args:
            ab: Tuple of values corresponding with A and B.
        """
        sel_a, sel_b = ab
        has_link = (
            self.links_df[self.links_df[["A", "B"]]].isin_dict({"A": sel_a, "B": sel_b}).any()
        )
        return has_link

    def is_connected(self, mode: str) -> bool:
        """Determines if the network graph is "strongly" connected.

        A graph is strongly connected if each vertex is reachable from every other vertex.

        Args:
            mode:  mode of the network, one of `drive`,`transit`,`walk`, `bike`
        """
        is_connected = nx.is_strongly_connected(self.get_modal_graph(mode))

        return is_connected
link_shapes_df

Add shape geometry to links if available.

returns: shapes merged to links dataframe

network_wrangler.roadway.network.RoadwayNetwork.model_net property

model_net

Return a ModelRoadwayNetwork object for this network.

The model network is lazily created and cached. It is invalidated when the network’s modification version changes.

network_wrangler.roadway.network.RoadwayNetwork.modification_version property

modification_version

Return the current modification version of the network.

This counter increments each time the network is modified and can be used for efficient change detection without computing expensive hashes.

network_wrangler.roadway.network.RoadwayNetwork.network_hash property

network_hash

Hash of the links and nodes dataframes.

Note: This is an expensive operation. For change detection, prefer using modification_version which is much faster.

network_wrangler.roadway.network.RoadwayNetwork.shapes_df property writable

shapes_df

Load and return RoadShapesTable.

If not already loaded, will read from shapes_file and return. If shapes_file is None, will return an empty dataframe with the right schema. If shapes_df is already set, will return that.

network_wrangler.roadway.network.RoadwayNetwork.summary property

summary

Quick summary dictionary of number of links, nodes.

network_wrangler.roadway.network.RoadwayNetwork.__str__

__str__()

Return string representation of RoadwayNetwork.

Returns:

Name Type Description
str

Summary string showing network statistics and dataframe contents.

Source code in network_wrangler/roadway/network.py
def __str__(self):
    """Return string representation of RoadwayNetwork.

    Returns:
        str: Summary string showing network statistics and dataframe contents.
    """
    my_str = f"RoadwayNetwork(nodes={len(self.nodes_df)}, links={len(self.links_df)})"
    my_str += f"\nnodes_df (type={type(self.nodes_df)}):\n{self.nodes_df}"
    my_str += f"\nlinks_df (type={type(self.links_df)}):\n{self.links_df}"
    return my_str
add_links(add_links_df, in_crs=LAT_LON_CRS)

Validate combined links_df with LinksSchema before adding to self.links_df.

Parameters:

Name Type Description Default
add_links_df DataFrame

Dataframe of additional links to add.

required
in_crs int

crs of input data. Defaults to LAT_LON_CRS.

LAT_LON_CRS
Source code in network_wrangler/roadway/network.py
def add_links(
    self,
    add_links_df: pd.DataFrame,
    in_crs: int = LAT_LON_CRS,
):
    """Validate combined links_df with LinksSchema before adding to self.links_df.

    Args:
        add_links_df: Dataframe of additional links to add.
        in_crs: crs of input data. Defaults to LAT_LON_CRS.
    """
    dupe_recs = self.links_df.model_link_id.isin(add_links_df.model_link_id)

    if dupe_recs.any():
        dupe_ids = self.links_df.loc[dupe_recs, "model_link_id"]
        WranglerLogger.error(
            f"Cannot add links with model_link_id already in network: {dupe_ids}"
        )
        msg = "Cannot add links with model_link_id already in network."
        raise LinkAddError(msg)

    if add_links_df.attrs.get("name") != "road_links":
        add_links_df = data_to_links_df(add_links_df, nodes_df=self.nodes_df, in_crs=in_crs)
    self.links_df = validate_df_to_model(
        concat_with_attr([self.links_df, add_links_df], axis=0), RoadLinksTable
    )
    self._mark_modified()

network_wrangler.roadway.network.RoadwayNetwork.add_nodes

add_nodes(add_nodes_df, in_crs=LAT_LON_CRS)

Validate combined nodes_df with NodesSchema before adding to self.nodes_df.

Parameters:

Name Type Description Default
add_nodes_df DataFrame

Dataframe of additional nodes to add.

required
in_crs int

crs of input data. Defaults to LAT_LON_CRS.

LAT_LON_CRS
Source code in network_wrangler/roadway/network.py
def add_nodes(
    self,
    add_nodes_df: pd.DataFrame,
    in_crs: int = LAT_LON_CRS,
):
    """Validate combined nodes_df with NodesSchema before adding to self.nodes_df.

    Args:
        add_nodes_df: Dataframe of additional nodes to add.
        in_crs: crs of input data. Defaults to LAT_LON_CRS.
    """
    dupe_ids = self.nodes_df.model_node_id.isin(add_nodes_df.model_node_id)
    if dupe_ids.any():
        WranglerLogger.error(
            f"Cannot add nodes with model_node_id already in network: {dupe_ids}"
        )
        msg = "Cannot add nodes with model_node_id already in network."
        raise NodeAddError(msg)
    WranglerLogger.debug(f"add_nodes(): self.nodes_df.tail()\n{self.nodes_df.tail()}")
    WranglerLogger.debug(f"add_nodes(): add_nodes_df:\n{add_nodes_df}")

    # this will perform validation to the nodes schema
    self.nodes_df = data_to_nodes_df(
        nodes_df=concat_with_attr([self.nodes_df, add_nodes_df], axis=0), in_crs=in_crs
    )
    # Ensure attrs are preserved after validation
    self.nodes_df.attrs.update(RoadNodesAttrs)
    if self.nodes_df.attrs.get("name") != "road_nodes":
        msg = f"Expected nodes_df to have name 'road_nodes', got {self.nodes_df.attrs.get('name')}"
        raise NotNodesError(msg)
    self._mark_modified()

network_wrangler.roadway.network.RoadwayNetwork.add_shapes

add_shapes(add_shapes_df, in_crs=LAT_LON_CRS)

Validate combined shapes_df with RoadShapesTable efore adding to self.shapes_df.

Parameters:

Name Type Description Default
add_shapes_df DataFrame

Dataframe of additional shapes to add.

required
in_crs int

crs of input data. Defaults to LAT_LON_CRS.

LAT_LON_CRS
Source code in network_wrangler/roadway/network.py
def add_shapes(
    self,
    add_shapes_df: pd.DataFrame,
    in_crs: int = LAT_LON_CRS,
):
    """Validate combined shapes_df with RoadShapesTable efore adding to self.shapes_df.

    Args:
        add_shapes_df: Dataframe of additional shapes to add.
        in_crs: crs of input data. Defaults to LAT_LON_CRS.
    """
    if len(self.shapes_df) > 0:
        dupe_ids = self.shapes_df["shape_id"].isin(add_shapes_df["shape_id"])
        if dupe_ids.any():
            msg = "Cannot add shapes with shape_id already in network."
            WranglerLogger.error(msg + f"\nDuplicates: {dupe_ids}")
            raise ShapeAddError(msg)

    if add_shapes_df.attrs.get("name") != "road_shapes":
        add_shapes_df = df_to_shapes_df(add_shapes_df, in_crs=in_crs, config=self.config)

    WranglerLogger.debug(f"add_shapes_df: \n{add_shapes_df}")
    WranglerLogger.debug(f"self.shapes_df: \n{self.shapes_df}")

    self.shapes_df = validate_df_to_model(
        concat_with_attr([self.shapes_df, add_shapes_df], axis=0), RoadShapesTable
    )

network_wrangler.roadway.network.RoadwayNetwork.apply

apply(project_card, transit_net=None, **kwargs)

Wrapper method to apply a roadway project, returning a new RoadwayNetwork instance.

Parameters:

Name Type Description Default
project_card ProjectCard | dict

either a dictionary of the project card object or ProjectCard instance

required
transit_net TransitNetwork | None

optional transit network which will be used to if project requires as noted in SECONDARY_TRANSIT_CARD_TYPES. If no transit network is provided, will skip anything related to transit network.

None
**kwargs

keyword arguments to pass to project application

{}
Source code in network_wrangler/roadway/network.py
def apply(
    self,
    project_card: ProjectCard | dict,
    transit_net: TransitNetwork | None = None,
    **kwargs,
) -> RoadwayNetwork:
    """Wrapper method to apply a roadway project, returning a new RoadwayNetwork instance.

    Args:
        project_card: either a dictionary of the project card object or ProjectCard instance
        transit_net: optional transit network which will be used to if project requires as
            noted in `SECONDARY_TRANSIT_CARD_TYPES`.  If no transit network is provided, will
            skip anything related to transit network.
        **kwargs: keyword arguments to pass to project application
    """
    if not (isinstance(project_card, ProjectCard | SubProject)):
        project_card = ProjectCard(project_card)

    # project_card.validate()
    if not project_card.valid:
        msg = f"Project card {project_card.project} not valid."
        WranglerLogger.error(msg)
        raise ProjectCardError(msg)

    if project_card._sub_projects:
        for sp in project_card._sub_projects:
            WranglerLogger.debug(f"- applying subproject: {sp.change_type}")
            self._apply_change(sp, transit_net=transit_net, **kwargs)
        return self
    return self._apply_change(project_card, transit_net=transit_net, **kwargs)

network_wrangler.roadway.network.RoadwayNetwork.clean_unused_nodes

clean_unused_nodes()

Removes any unused nodes from network that aren’t referenced by links_df.

NOTE: does not check if these nodes are used by transit, so use with caution.

Source code in network_wrangler/roadway/network.py
def clean_unused_nodes(self):
    """Removes any unused nodes from network that aren't referenced by links_df.

    NOTE: does not check if these nodes are used by transit, so use with caution.
    """
    from .nodes.nodes import node_ids_without_links

    node_ids = node_ids_without_links(self.nodes_df, self.links_df)
    self.nodes_df = self.nodes_df.drop(node_ids)
    self._mark_modified()

network_wrangler.roadway.network.RoadwayNetwork.clean_unused_shapes

clean_unused_shapes()

Removes any unused shapes from network that aren’t referenced by links_df.

Source code in network_wrangler/roadway/network.py
def clean_unused_shapes(self):
    """Removes any unused shapes from network that aren't referenced by links_df."""
    from .shapes.shapes import shape_ids_without_links

    del_shape_ids = shape_ids_without_links(self.shapes_df, self.links_df)
    self.shapes_df = self.shapes_df.drop(del_shape_ids)
delete_links(
    selection_dict,
    clean_nodes=False,
    clean_shapes=False,
    transit_net=None,
)

Deletes links based on selection dictionary and optionally associated nodes and shapes.

Parameters:

Name Type Description Default
selection_dict SelectLinks

Dictionary describing link selections as follows: all: Optional[bool] = False. If true, will select all. name: Optional[list[str]] ref: Optional[list[str]] osm_link_id:Optional[list[str]] model_link_id: Optional[list[int]] modes: Optional[list[str]]. Defaults to “any” ignore_missing: if true, will not error when defaults to True. …plus any other link property to select on top of these.

required
clean_nodes bool

If True, will clean nodes uniquely associated with deleted links. Defaults to False.

False
clean_shapes bool

If True, will clean nodes uniquely associated with deleted links. Defaults to False.

False
transit_net TransitNetwork

If provided, will check TransitNetwork and warn if deletion breaks transit shapes. Defaults to None.

None
Source code in network_wrangler/roadway/network.py
def delete_links(
    self,
    selection_dict: dict | SelectLinksDict,
    clean_nodes: bool = False,
    clean_shapes: bool = False,
    transit_net: TransitNetwork | None = None,
):
    """Deletes links based on selection dictionary and optionally associated nodes and shapes.

    Args:
        selection_dict (SelectLinks): Dictionary describing link selections as follows:
            `all`: Optional[bool] = False. If true, will select all.
            `name`: Optional[list[str]]
            `ref`: Optional[list[str]]
            `osm_link_id`:Optional[list[str]]
            `model_link_id`: Optional[list[int]]
            `modes`: Optional[list[str]]. Defaults to "any"
            `ignore_missing`: if true, will not error when defaults to True.
            ...plus any other link property to select on top of these.
        clean_nodes (bool, optional): If True, will clean nodes uniquely associated with
            deleted links. Defaults to False.
        clean_shapes (bool, optional): If True, will clean nodes uniquely associated with
            deleted links. Defaults to False.
        transit_net (TransitNetwork, optional): If provided, will check TransitNetwork and
            warn if deletion breaks transit shapes. Defaults to None.
    """
    if not isinstance(selection_dict, SelectLinksDict):
        selection_dict = SelectLinksDict(**selection_dict)
    selection_dict = selection_dict.model_dump(exclude_none=True, by_alias=True)
    selection = self.get_selection({"links": selection_dict})
    if isinstance(selection, RoadwayNodeSelection):
        msg = "Selection should be for links, but got nodes."
        raise SelectionError(msg)
    if clean_nodes:
        node_ids_to_delete = node_ids_unique_to_link_ids(
            selection.selected_links, selection.selected_links_df, self.nodes_df
        )
        WranglerLogger.debug(
            f"Dropping nodes associated with dropped links: \n{node_ids_to_delete}"
        )
        self.nodes_df = delete_nodes_by_ids(self.nodes_df, del_node_ids=node_ids_to_delete)

    if clean_shapes:
        shape_ids_to_delete = shape_ids_unique_to_link_ids(
            selection.selected_links, selection.selected_links_df, self.shapes_df
        )
        WranglerLogger.debug(
            f"Dropping shapes associated with dropped links: \n{shape_ids_to_delete}"
        )
        self.shapes_df = delete_shapes_by_ids(
            self.shapes_df, del_shape_ids=shape_ids_to_delete
        )

    self.links_df = delete_links_by_ids(
        self.links_df,
        selection.selected_links,
        ignore_missing=selection.ignore_missing,
        transit_net=transit_net,
    )
    self._mark_modified()

network_wrangler.roadway.network.RoadwayNetwork.delete_nodes

delete_nodes(selection_dict, remove_links=False)

Deletes nodes from roadway network. Wont delete nodes used by links in network.

Parameters:

Name Type Description Default
selection_dict dict | SelectNodesDict

dictionary of node selection criteria in the form of a SelectNodesDict.

required
remove_links bool

if True, will remove any links that are associated with the nodes. If False, will only remove nodes if they are not associated with any links. Defaults to False.

False

Raises:

Type Description
NodeDeletionError

If not ignore_missing and selected nodes to delete aren’t in network

Source code in network_wrangler/roadway/network.py
def delete_nodes(
    self,
    selection_dict: dict | SelectNodesDict,
    remove_links: bool = False,
) -> None:
    """Deletes nodes from roadway network. Wont delete nodes used by links in network.

    Args:
        selection_dict: dictionary of node selection criteria in the form of a SelectNodesDict.
        remove_links: if True, will remove any links that are associated with the nodes.
            If False, will only remove nodes if they are not associated with any links.
            Defaults to False.

    Raises:
        NodeDeletionError: If not ignore_missing and selected nodes to delete aren't in network
    """
    if not isinstance(selection_dict, SelectNodesDict):
        selection_dict = SelectNodesDict(**selection_dict)
    selection_dict = selection_dict.model_dump(exclude_none=True, by_alias=True)
    _selection = self.get_selection({"nodes": selection_dict})
    assert isinstance(_selection, RoadwayNodeSelection)  # for mypy
    selection: RoadwayNodeSelection = _selection
    if remove_links:
        del_node_ids = selection.selected_nodes
        link_ids = self.links_with_nodes(selection.selected_nodes).model_link_id.to_list()
        WranglerLogger.info(f"Removing {len(link_ids)} links associated with nodes.")
        self.delete_links({"model_link_id": link_ids})
    else:
        unused_node_ids = node_ids_without_links(self.nodes_df, self.links_df)
        del_node_ids = list(set(selection.selected_nodes).intersection(unused_node_ids))

    self.nodes_df = delete_nodes_by_ids(
        self.nodes_df, del_node_ids, ignore_missing=selection.ignore_missing
    )
    self._mark_modified()

network_wrangler.roadway.network.RoadwayNetwork.get_modal_graph

get_modal_graph(mode)

Return a networkx graph of the network for a specific mode.

Parameters:

Name Type Description Default
mode

mode of the network, one of drive,transit,walk, bike

required
Source code in network_wrangler/roadway/network.py
def get_modal_graph(self, mode) -> MultiDiGraph:
    """Return a networkx graph of the network for a specific mode.

    Args:
        mode: mode of the network, one of `drive`,`transit`,`walk`, `bike`
    """
    from .graph import net_to_graph

    # Use modification version for efficient change detection
    current_version = (self._modification_version, mode)
    if self._modal_graphs[mode].get("version") != current_version:
        self._modal_graphs[mode]["graph"] = net_to_graph(self, mode)
        self._modal_graphs[mode]["version"] = current_version

    return self._modal_graphs[mode]["graph"]

network_wrangler.roadway.network.RoadwayNetwork.get_property_by_timespan_and_group

get_property_by_timespan_and_group(
    link_property,
    category=DEFAULT_CATEGORY,
    timespan=DEFAULT_TIMESPAN,
    strict_timespan_match=False,
    min_overlap_minutes=60,
)

Returns a new dataframe with model_link_id and link property by category and timespan.

Convenience method for backward compatability.

Parameters:

Name Type Description Default
link_property str

link property to query

required
category str | int | None

category to query or a list of categories. Defaults to DEFAULT_CATEGORY.

DEFAULT_CATEGORY
timespan TimespanString | None

timespan to query in the form of [“HH:MM”,”HH:MM”]. Defaults to DEFAULT_TIMESPAN.

DEFAULT_TIMESPAN
strict_timespan_match bool

If True, will only return links that match the timespan exactly. Defaults to False.

False
min_overlap_minutes int

If strict_timespan_match is False, will return links that overlap with the timespan by at least this many minutes. Defaults to 60.

60
Source code in network_wrangler/roadway/network.py
def get_property_by_timespan_and_group(
    self,
    link_property: str,
    category: str | int | None = DEFAULT_CATEGORY,
    timespan: TimespanString | None = DEFAULT_TIMESPAN,
    strict_timespan_match: bool = False,
    min_overlap_minutes: int = 60,
) -> Any:
    """Returns a new dataframe with model_link_id and link property by category and timespan.

    Convenience method for backward compatability.

    Args:
        link_property: link property to query
        category: category to query or a list of categories. Defaults to DEFAULT_CATEGORY.
        timespan: timespan to query in the form of ["HH:MM","HH:MM"].
            Defaults to DEFAULT_TIMESPAN.
        strict_timespan_match: If True, will only return links that match the timespan exactly.
            Defaults to False.
        min_overlap_minutes: If strict_timespan_match is False, will return links that overlap
            with the timespan by at least this many minutes. Defaults to 60.
    """
    from .links.scopes import prop_for_scope

    return prop_for_scope(
        self.links_df,
        link_property,
        timespan=timespan,
        category=category,
        strict_timespan_match=strict_timespan_match,
        min_overlap_minutes=min_overlap_minutes,
    )

network_wrangler.roadway.network.RoadwayNetwork.get_selection

get_selection(selection_dict, overwrite=False)

Return selection if it already exists, otherwise performs selection.

Parameters:

Name Type Description Default
selection_dict dict

SelectFacility dictionary.

required
overwrite bool

if True, will overwrite any previously cached searches. Defaults to False.

False
Source code in network_wrangler/roadway/network.py
def get_selection(
    self,
    selection_dict: dict | SelectFacility,
    overwrite: bool = False,
) -> RoadwayNodeSelection | RoadwayLinkSelection:
    """Return selection if it already exists, otherwise performs selection.

    Args:
        selection_dict (dict): SelectFacility dictionary.
        overwrite: if True, will overwrite any previously cached searches. Defaults to False.
    """
    key = _create_selection_key(selection_dict)
    if (key in self._selections) and not overwrite:
        WranglerLogger.debug(f"Using cached selection from key: {key}")
        return self._selections[key]

    if isinstance(selection_dict, SelectFacility):
        selection_data = selection_dict
    elif isinstance(selection_dict, SelectLinksDict):
        selection_data = SelectFacility(links=selection_dict)
    elif isinstance(selection_dict, SelectNodesDict):
        selection_data = SelectFacility(nodes=selection_dict)
    elif isinstance(selection_dict, dict):
        selection_data = SelectFacility(**selection_dict)
    else:
        msg = "selection_dict arg must be a dictionary or SelectFacility model."
        WranglerLogger.error(
            msg + f" Received: {selection_dict} of type {type(selection_dict)}"
        )
        raise SelectionError(msg)

    WranglerLogger.debug(f"Getting selection from key: {key}  selection_data={selection_data}")
    if "links" in selection_data.fields:
        return RoadwayLinkSelection(self, selection_dict)
    if "nodes" in selection_data.fields:
        return RoadwayNodeSelection(self, selection_dict)
    msg = "Selection data should have either 'links' or 'nodes'."
    WranglerLogger.error(msg + f" Received: {selection_dict}")
    raise SelectionError(msg)
has_link(ab)

Returns true if network has links with AB values.

Parameters:

Name Type Description Default
ab tuple

Tuple of values corresponding with A and B.

required
Source code in network_wrangler/roadway/network.py
def has_link(self, ab: tuple) -> bool:
    """Returns true if network has links with AB values.

    Args:
        ab: Tuple of values corresponding with A and B.
    """
    sel_a, sel_b = ab
    has_link = (
        self.links_df[self.links_df[["A", "B"]]].isin_dict({"A": sel_a, "B": sel_b}).any()
    )
    return has_link

network_wrangler.roadway.network.RoadwayNetwork.has_node

has_node(model_node_id)

Queries if network has node based on model_node_id.

Parameters:

Name Type Description Default
model_node_id int

model_node_id to check for.

required
Source code in network_wrangler/roadway/network.py
def has_node(self, model_node_id: int) -> bool:
    """Queries if network has node based on model_node_id.

    Args:
        model_node_id: model_node_id to check for.
    """
    has_node = self.nodes_df[self.nodes_df.model_node_id].isin([model_node_id]).any()

    return has_node

network_wrangler.roadway.network.RoadwayNetwork.is_connected

is_connected(mode)

Determines if the network graph is “strongly” connected.

A graph is strongly connected if each vertex is reachable from every other vertex.

Parameters:

Name Type Description Default
mode str

mode of the network, one of drive,transit,walk, bike

required
Source code in network_wrangler/roadway/network.py
def is_connected(self, mode: str) -> bool:
    """Determines if the network graph is "strongly" connected.

    A graph is strongly connected if each vertex is reachable from every other vertex.

    Args:
        mode:  mode of the network, one of `drive`,`transit`,`walk`, `bike`
    """
    is_connected = nx.is_strongly_connected(self.get_modal_graph(mode))

    return is_connected
links_with_link_ids(link_ids)

Return subset of links_df based on link_ids list.

Source code in network_wrangler/roadway/network.py
def links_with_link_ids(self, link_ids: list[int]) -> pd.DataFrame:
    """Return subset of links_df based on link_ids list."""
    return filter_links_to_ids(self.links_df, link_ids)
links_with_nodes(node_ids)

Return subset of links_df based on node_ids list.

Source code in network_wrangler/roadway/network.py
def links_with_nodes(self, node_ids: list[int]) -> pd.DataFrame:
    """Return subset of links_df based on node_ids list."""
    return filter_links_to_node_ids(self.links_df, node_ids)

network_wrangler.roadway.network.RoadwayNetwork.modal_graph_hash

modal_graph_hash(mode)

Hash of the links in order to detect a network change from when graph created.

Note: This is an expensive operation. For internal change detection, get_modal_graph uses modification_version instead.

Source code in network_wrangler/roadway/network.py
def modal_graph_hash(self, mode) -> str:
    """Hash of the links in order to detect a network change from when graph created.

    Note: This is an expensive operation. For internal change detection,
    get_modal_graph uses modification_version instead.
    """
    _value = str.encode(self.links_df.df_hash() + "-" + mode)
    _hash = hashlib.sha256(_value).hexdigest()

    return _hash

network_wrangler.roadway.network.RoadwayNetwork.move_nodes

move_nodes(node_geometry_change_table)

Moves nodes based on updated geometry along with associated links and shape geometry.

Parameters:

Name Type Description Default
node_geometry_change_table DataFrame

a table with model_node_id, X, Y, and CRS.

required
Source code in network_wrangler/roadway/network.py
def move_nodes(
    self,
    node_geometry_change_table: pd.DataFrame,
):
    """Moves nodes based on updated geometry along with associated links and shape geometry.

    Args:
        node_geometry_change_table: a table with model_node_id, X, Y, and CRS.
    """
    node_geometry_change_table = NodeGeometryChangeTable(node_geometry_change_table)
    node_ids = node_geometry_change_table.model_node_id.to_list()
    WranglerLogger.debug(f"Moving nodes:\n{node_geometry_change_table}")
    self.nodes_df = edit_node_geometry(self.nodes_df, node_geometry_change_table)
    WranglerLogger.debug(f"Completed edit_node_geometry()")
    self.links_df = edit_link_geometry_from_nodes(self.links_df, self.nodes_df, node_ids)
    WranglerLogger.debug(f"Completed edit_link_geometry_from_nodes()")
    self.shapes_df = edit_shape_geometry_from_nodes(
        self.shapes_df, self.links_df, self.nodes_df, node_ids
    )
    WranglerLogger.debug(f"Completed edit_shape_geometry_from_nodes()")
    self._mark_modified()

network_wrangler.roadway.network.RoadwayNetwork.node_coords

node_coords(model_node_id)

Return coordinates (x, y) of a node based on model_node_id.

Source code in network_wrangler/roadway/network.py
def node_coords(self, model_node_id: int) -> tuple:
    """Return coordinates (x, y) of a node based on model_node_id."""
    try:
        node = self.nodes_df[self.nodes_df.model_node_id == model_node_id]
    except ValueError as err:
        msg = f"Node with model_node_id {model_node_id} not found."
        WranglerLogger.error(msg)
        raise NodeNotFoundError(msg) from err
    return node.geometry.x.values[0], node.geometry.y.values[0]
nodes_in_links()

Returns subset of self.nodes_df that are in self.links_df.

Source code in network_wrangler/roadway/network.py
def nodes_in_links(self) -> pd.DataFrame:
    """Returns subset of self.nodes_df that are in self.links_df."""
    return filter_nodes_to_links(self.links_df, self.nodes_df)
split_link(
    A,
    B,
    new_model_node_id,
    fraction,
    split_reverse_link=True,
)

Splits a link into two links at a specified fraction of its length.

Parameters:

Name Type Description Default
A int

The model_node_id of the start node of the link to split.

required
B int

The model_node_id of the end node of the link to split.

required
new_model_node_id int

The model_node_id for the new node to be created at the split point.

required
fraction float

The fraction along the link’s length where the split occurs (0 < fraction < 1).

required
split_reverse_link bool

If True, also splits the reverse link if it exists. Defaults to True.

True

Raises:

Type Description
ValueError

If the link doesn’t exist, fraction is invalid, or new_model_node_id already exists.

Source code in network_wrangler/roadway/network.py
def split_link(  # noqa: PLR0912, PLR0915
    self,
    A: int,
    B: int,
    new_model_node_id: int,
    fraction: float,
    split_reverse_link: bool = True,
) -> None:
    """Splits a link into two links at a specified fraction of its length.

    Args:
        A: The model_node_id of the start node of the link to split.
        B: The model_node_id of the end node of the link to split.
        new_model_node_id: The model_node_id for the new node to be created at the split point.
        fraction: The fraction along the link's length where the split occurs (0 < fraction < 1).
        split_reverse_link: If True, also splits the reverse link if it exists. Defaults to True.

    Raises:
        ValueError: If the link doesn't exist, fraction is invalid, or new_model_node_id already exists.

    TODO: Use SelectionDictionary rather than A,B?
    TODO: Make new_model_node_id an optional argument because we can use
          `network_wrangler.roadway.nodes.create.generate_node_ids()`
    """
    WranglerLogger.debug(f"split_link() self.links_df.head():\n{self.links_df.head()}")
    # Find the link with given A and B nodes
    matching_links = self.links_df[(self.links_df["A"] == A) & (self.links_df["B"] == B)]

    if matching_links.empty:
        msg = f"Link from node {A} to node {B} not found in network."
        raise ValueError(msg)

    # If multiple links exist between A and B, use the first one and warn
    if len(matching_links) > 1:
        WranglerLogger.warning(
            f"Multiple links found from node {A} to {B}. Using first link with model_link_id {matching_links.index[0]}."
        )

    model_link_idx = matching_links.index[0]

    if not 0 < fraction < 1:
        msg = f"Fraction must be between 0 and 1 (exclusive), got {fraction}."
        raise ValueError(msg)

    if new_model_node_id in self.nodes_df.index:
        msg = f"Node with model_node_id {new_model_node_id} already exists in network."
        raise ValueError(msg)

    # Get the original link
    orig_link = self.links_df.loc[model_link_idx].copy()
    WranglerLogger.debug(f"Splitting link:\n{orig_link}")

    # Get the geometry to split (use shape geometry if available)
    if pd.notna(orig_link.get("shape_id")) and orig_link["shape_id"] in self.shapes_df.index:
        geometry = self.shapes_df.loc[orig_link["shape_id"], "geometry"]
    else:
        geometry = orig_link["geometry"]

    # Calculate the split point
    split_point = geometry.interpolate(fraction, normalized=True)

    # Create the new node
    # The attribute osm_node_id is not required so don't set it
    new_node_data = {
        "model_node_id": new_model_node_id,
        "X": split_point.x,
        "Y": split_point.y,
    }

    # Copy additional attributes from node A if they exist
    node_a_data = self.nodes_df.loc[self.nodes_df["model_node_id"] == orig_link["A"]].iloc[0]
    # Copy all columns except geometry, X, Y, and model_node_id
    for col in self.nodes_df.columns:
        if (
            col not in ["model_node_id", "X", "Y", "geometry", "osm_node_id"]
            and col in node_a_data.index
            and pd.notna(node_a_data[col])
        ):
            new_node_data[col] = node_a_data[col]

    # Create DataFrame for new node and add it
    new_node_df = pd.DataFrame([new_node_data])
    self.add_nodes(new_node_df)

    # Split the geometry
    # Create a small circle around the split point to ensure clean split
    split_geom = split_point.buffer(0.00001)  # Small buffer in degrees
    split_result = split(geometry, split_geom)

    if len(split_result.geoms) >= MIN_SPLIT_SEGMENTS:
        geom1 = split_result.geoms[0]
        geom2 = split_result.geoms[-1]
    else:
        # Fallback: manually create two linestrings
        coords = list(geometry.coords)
        split_idx = int(len(coords) * fraction)
        if split_idx == 0:
            split_idx = 1
        elif split_idx >= len(coords):
            split_idx = len(coords) - 1

        # Insert the split point at the right position
        coords_1 = [*coords[:split_idx], (split_point.x, split_point.y)]
        coords_2 = [(split_point.x, split_point.y), *coords[split_idx:]]

        geom1 = LineString(coords_1)
        geom2 = LineString(coords_2)

    # Create two new links
    # Find the next available model_link_ids
    max_link_id = self.links_df["model_link_id"].max()
    new_link1_id = max_link_id + 1
    new_link2_id = max_link_id + 2

    # First link: A to new node
    link1_data = orig_link.to_dict()
    link1_data["model_link_id"] = new_link1_id
    link1_data["B"] = new_model_node_id
    link1_data["geometry"] = geom1
    link1_data["distance"] = (
        orig_link.get("distance", 0) * fraction if "distance" in orig_link else None
    )
    # Clear shape_id as we're creating new geometry
    link1_data["shape_id"] = None

    # Second link: new node to B
    link2_data = orig_link.to_dict()
    link2_data["model_link_id"] = new_link2_id
    link2_data["A"] = new_model_node_id
    link2_data["geometry"] = geom2
    link2_data["distance"] = (
        orig_link.get("distance", 0) * (1 - fraction) if "distance" in orig_link else None
    )
    # Clear shape_id as we're creating new geometry
    link2_data["shape_id"] = None

    # Handle reverse link if requested
    reverse_link_ids = []
    if split_reverse_link:
        # Look for reverse link (B->A)
        reverse_link = self.links_df[
            (self.links_df["A"] == orig_link["B"]) & (self.links_df["B"] == orig_link["A"])
        ]

        if not reverse_link.empty:
            reverse_link_data = reverse_link.iloc[0].copy()

            # Get reverse geometry
            if (
                pd.notna(reverse_link_data.get("shape_id"))
                and reverse_link_data["shape_id"] in self.shapes_df.index
            ):
                reverse_geometry = self.shapes_df.loc[
                    reverse_link_data["shape_id"], "geometry"
                ]
            else:
                reverse_geometry = reverse_link_data["geometry"]

            # Split at (1 - fraction) for reverse
            reverse_split_point = reverse_geometry.interpolate(1 - fraction, normalized=True)

            # Split the reverse geometry
            reverse_split_geom = reverse_split_point.buffer(0.00001)
            reverse_split_result = split(reverse_geometry, reverse_split_geom)

            if len(reverse_split_result.geoms) >= MIN_SPLIT_SEGMENTS:
                reverse_geom1 = reverse_split_result.geoms[0]
                reverse_geom2 = reverse_split_result.geoms[-1]
            else:
                # Fallback for reverse
                coords = list(reverse_geometry.coords)
                split_idx = int(len(coords) * (1 - fraction))
                if split_idx == 0:
                    split_idx = 1
                elif split_idx >= len(coords):
                    split_idx = len(coords) - 1

                coords_1 = [*coords[:split_idx], (split_point.x, split_point.y)]
                coords_2 = [(split_point.x, split_point.y), *coords[split_idx:]]

                reverse_geom1 = LineString(coords_1)
                reverse_geom2 = LineString(coords_2)

            # Create reverse links
            new_link3_id = max_link_id + 3
            new_link4_id = max_link_id + 4

            # Reverse link 1: original B to new node
            link3_data = reverse_link_data.to_dict()
            link3_data["model_link_id"] = new_link3_id
            link3_data["B"] = new_model_node_id
            link3_data["geometry"] = reverse_geom1
            link3_data["distance"] = (
                reverse_link_data.get("distance", 0) * (1 - fraction)
                if "distance" in reverse_link_data
                else None
            )
            link3_data["shape_id"] = None

            # Reverse link 2: new node to original A
            link4_data = reverse_link_data.to_dict()
            link4_data["model_link_id"] = new_link4_id
            link4_data["A"] = new_model_node_id
            link4_data["geometry"] = reverse_geom2
            link4_data["distance"] = (
                reverse_link_data.get("distance", 0) * fraction
                if "distance" in reverse_link_data
                else None
            )
            link4_data["shape_id"] = None

            reverse_link_ids = [reverse_link_data["model_link_id"]]

    # Delete original links - we need to delete by the model_link_id values
    orig_model_link_id = orig_link["model_link_id"]
    links_to_delete = [orig_model_link_id, *reverse_link_ids]
    self.delete_links(
        {"model_link_id": links_to_delete, "modes": ["any"]},
        clean_nodes=False,
        clean_shapes=False,
    )

    # Add new links
    new_links_data = [link1_data, link2_data]
    if reverse_link_ids:
        new_links_data.extend([link3_data, link4_data])

    new_links_df = pd.DataFrame(new_links_data)
    self.add_links(new_links_df)

    WranglerLogger.info(
        f"Split link {orig_model_link_id} at fraction {fraction} with new node {new_model_node_id}. "
        f"Created links {new_link1_id} and {new_link2_id}."
    )

    if reverse_link_ids:
        WranglerLogger.info(
            f"Also split reverse link {reverse_link_ids[0]} creating links {new_link3_id} and {new_link4_id}."
        )

network_wrangler.roadway.network.RoadwayNetwork.validate_config classmethod

validate_config(v)

Validate config.

Source code in network_wrangler/roadway/network.py
@field_validator("config")
@classmethod
def validate_config(cls, v):
    """Validate config."""
    return load_wrangler_config(v)
validate_links_df(v)

Validate links_df to RoadLinksTable and coerce CRS.

Source code in network_wrangler/roadway/network.py
@field_validator("links_df", mode="before")
@classmethod
def validate_links_df(cls, v):
    """Validate links_df to RoadLinksTable and coerce CRS."""
    v = validate_df_to_model(v, RoadLinksTable)
    if hasattr(v, "crs") and v.crs != LAT_LON_CRS:
        WranglerLogger.warning(
            f"CRS of links_df ({v.crs}) doesn't match network crs {LAT_LON_CRS}. \
                Changing to network crs."
        )
        v = v.to_crs(LAT_LON_CRS)
    return v

network_wrangler.roadway.network.RoadwayNetwork.validate_nodes_df classmethod

validate_nodes_df(v)

Validate nodes_df to RoadNodesTable and coerce CRS.

Source code in network_wrangler/roadway/network.py
@field_validator("nodes_df", mode="before")
@classmethod
def validate_nodes_df(cls, v):
    """Validate nodes_df to RoadNodesTable and coerce CRS."""
    v = validate_df_to_model(v, RoadNodesTable)
    if hasattr(v, "crs") and v.crs != LAT_LON_CRS:
        WranglerLogger.warning(
            f"CRS of nodes_df ({v.crs}) doesn't match network crs {LAT_LON_CRS}. \
                Changing to network crs."
        )
        v = v.to_crs(LAT_LON_CRS)
    return v

network_wrangler.roadway.network.RoadwayNetwork.validate_referential_integrity

validate_referential_integrity()

Validate that all nodes referenced in links exist in nodes table.

Source code in network_wrangler/roadway/network.py
@model_validator(mode="after")
def validate_referential_integrity(self):
    """Validate that all nodes referenced in links exist in nodes table."""
    WranglerLogger.debug(
        "validate_referential_integrity(): Validating referential integrity between links and nodes"
    )
    try:
        validate_links_have_nodes(self.links_df, self.nodes_df)
    except Exception as e:
        WranglerLogger.error(f"Referential integrity validation failed: {e}")
        raise

    return self
add_incident_link_data_to_nodes(
    links_df, nodes_df, link_variables=None
)

Add data from links going to/from nodes to node.

Parameters:

Name Type Description Default
links_df DataFrame

Will assess connectivity of this links list

required
nodes_df DataFrame

Will assess connectivity of this nodes list

required
link_variables list | None

list of columns in links dataframe to add to incident nodes

None

Returns:

Type Description
DataFrame

nodes DataFrame with link data where length is N*number of links going in/out

Source code in network_wrangler/roadway/network.py
def add_incident_link_data_to_nodes(
    links_df: pd.DataFrame,
    nodes_df: pd.DataFrame,
    link_variables: list | None = None,
) -> pd.DataFrame:
    """Add data from links going to/from nodes to node.

    Args:
        links_df: Will assess connectivity of this links list
        nodes_df: Will assess connectivity of this nodes list
        link_variables: list of columns in links dataframe to add to incident nodes

    Returns:
        nodes DataFrame with link data where length is N*number of links going in/out
    """
    WranglerLogger.debug("Adding following link data to nodes: ".format())
    link_variables = link_variables or []

    _link_vals_to_nodes = [x for x in link_variables if x in links_df.columns]
    if link_variables not in _link_vals_to_nodes:
        WranglerLogger.warning(
            f"Following columns not in links_df and wont be added to nodes: {list(set(link_variables) - set(_link_vals_to_nodes))} "
        )

    _nodes_from_links_A = nodes_df.merge(
        links_df[[links_df.A, *_link_vals_to_nodes]],
        how="outer",
        left_on=nodes_df.model_node_id,
        right_on=links_df.A,
    )
    _nodes_from_links_B = nodes_df.merge(
        links_df[[links_df.B, *_link_vals_to_nodes]],
        how="outer",
        left_on=nodes_df.model_node_id,
        right_on=links_df.B,
    )
    _nodes_from_links_ab = concat_with_attr([_nodes_from_links_A, _nodes_from_links_B])

    return _nodes_from_links_ab

TransitNetwork class for representing a transit network.

Transit Networks are represented as a Wrangler-flavored GTFS Feed and optionally mapped to a RoadwayNetwork object. The TransitNetwork object is the primary object for managing transit networks in Wrangler.

Usage:

1
2
3
4
5
6
7
8
```python
import network_wrangler as wr

t = wr.load_transit(stpaul_gtfs)
t.road_net = wr.load_roadway(stpaul_roadway)
t = t.apply(project_card)
write_transit(t, "output_dir")
```

network_wrangler.transit.network.TransitNetwork

Representation of a Transit Network.

Typical usage example:

import network_wrangler as wr

tc = wr.load_transit(stpaul_gtfs)

Attributes:

Name Type Description
feed Feed

Feed object with interlinked tables.

road_net RoadwayNetwork

Associated roadway network object.

graph MultiDiGraph

Graph for associated roadway network object.

config WranglerConfig

Configuration object for the transit network.

feed_path str

Where the feed was read in from.

validated_frequencies bool

The frequencies have been validated.

validated_road_network_consistency

The network has been validated against the road network.

Source code in network_wrangler/transit/network.py
class TransitNetwork:
    """Representation of a Transit Network.

    Typical usage example:
    ``` py
    import network_wrangler as wr

    tc = wr.load_transit(stpaul_gtfs)
    ```

    Attributes:
        feed (Feed): Feed object with interlinked tables.
        road_net (RoadwayNetwork): Associated roadway network object.
        graph (nx.MultiDiGraph): Graph for associated roadway network object.
        config (WranglerConfig): Configuration object for the transit network.
        feed_path (str): Where the feed was read in from.
        validated_frequencies (bool): The frequencies have been validated.
        validated_road_network_consistency (): The network has been validated against
            the road network.
    """

    TIME_COLS: ClassVar = ["arrival_time", "departure_time", "start_time", "end_time"]

    def __init__(self, feed: Feed, config: WranglerConfig = DefaultConfig) -> None:
        """Constructor for TransitNetwork.

        Args:
            feed: Feed object representing the transit network gtfs tables
            config: WranglerConfig object. Defaults to DefaultConfig.
        """
        WranglerLogger.debug("Creating new TransitNetwork.")

        self._road_net: RoadwayNetwork | None = None
        self.feed: Feed = feed
        self.graph: nx.MultiDiGraph = None
        self.config: WranglerConfig = config
        # initialize
        self._consistent_with_road_net = False

        # cached selections
        self._selections: dict[str, TransitSelection] = {}

    @property
    def feed_path(self):
        """Pass through property from Feed."""
        return self.feed.feed_path

    @property
    def applied_projects(self) -> list[str]:
        """List of projects applied to the network.

        Note: This may or may not return a full accurate account of all the applied projects.
        For better project accounting, please leverage the scenario object.
        """
        return _get_applied_projects_from_tables(self.feed)

    @property
    def feed(self):
        """Feed associated with the transit network."""
        return self._feed

    @feed.setter
    def feed(self, feed: Feed):
        if not isinstance(feed, Feed):
            msg = (
                f"TransitNetwork's feed value must be a valid Feed instance. "
                + f"This is a {type(feed)}."
            )
            WranglerLogger.error(msg)
            raise TransitValidationError(msg)
        if self._road_net is None or transit_road_net_consistency(feed, self._road_net):
            self._feed = feed
            self._stored_feed_version = feed.modification_version
        else:
            msg = "Can't assign Feed inconsistent with set Roadway Network."
            WranglerLogger.error(msg)
            raise TransitRoadwayConsistencyError(msg)

    @property
    def road_net(self) -> None | RoadwayNetwork:
        """Roadway network associated with the transit network."""
        return self._road_net

    @road_net.setter
    def road_net(self, road_net_in: RoadwayNetwork):
        if road_net_in is None or road_net_in.__class__.__name__ != "RoadwayNetwork":
            msg = (
                f"TransitNetwork's road_net: value must be a valid RoadwayNetwork instance."
                + f"This is a {type(road_net_in)}."
            )
            WranglerLogger.error(msg)
            raise TransitValidationError(msg)
        if transit_road_net_consistency(self.feed, road_net_in):
            self._road_net = road_net_in
            self._stored_road_net_version = road_net_in.modification_version
            self._consistent_with_road_net = True
        else:
            msg = (
                "Can't assign inconsistent RoadwayNetwork - Roadway Network not "
                + "set, but can be referenced separately."
            )
            WranglerLogger.error(msg)
            raise TransitRoadwayConsistencyError(msg)

    @property
    def feed_hash(self):
        """Return the hash of the feed."""
        return self.feed.hash

    @property
    def consistent_with_road_net(self) -> bool:
        """Indicate if road_net is consistent with transit network.

        Will return True if road_net is None, but provide a warning.

        Checks the modification version of when consistency was last evaluated. If transit network
        or roadway network has changed, will re-evaluate consistency and return the updated value.

        Returns:
            Boolean indicating if road_net is consistent with transit network.
        """
        if self.road_net is None:
            WranglerLogger.warning("Roadway Network not set, cannot accurately check consistency.")
            return True
        updated_road = self.road_net.modification_version != self._stored_road_net_version
        updated_feed = self.feed.modification_version != self._stored_feed_version

        if updated_road or updated_feed:
            self._consistent_with_road_net = transit_road_net_consistency(self.feed, self.road_net)
            self._stored_road_net_version = self.road_net.modification_version
            self._stored_feed_version = self.feed.modification_version
        return self._consistent_with_road_net

    def __deepcopy__(self, memo):
        """Returns copied TransitNetwork instance with deep copy of Feed but not roadway net."""
        COPY_REF_NOT_VALUE = ["_road_net"]
        # Create a new, empty instance
        copied_net = self.__class__.__new__(self.__class__)
        # Return the new TransitNetwork instance
        attribute_dict = vars(self)

        # Copy the attributes to the new instance
        for attr_name, attr_value in attribute_dict.items():
            # WranglerLogger.debug(f"Copying {attr_name}")
            if attr_name in COPY_REF_NOT_VALUE:
                # If the attribute is in the COPY_REF_NOT_VALUE list, assign the reference
                setattr(copied_net, attr_name, attr_value)
            else:
                # WranglerLogger.debug(f"making deep copy: {attr_name}")
                # For other attributes, perform a deep copy
                setattr(copied_net, attr_name, copy.deepcopy(attr_value, memo))

        return copied_net

    def deepcopy(self):
        """Returns copied TransitNetwork instance with deep copy of Feed but not roadway net."""
        return copy.deepcopy(self)

    @property
    def stops_gdf(self) -> gpd.GeoDataFrame:
        """Return stops as a GeoDataFrame using set roadway geometry."""
        ref_nodes = self.road_net.nodes_df if self.road_net is not None else None
        return to_points_gdf(self.feed.stops, ref_nodes_df=ref_nodes)

    @property
    def shapes_gdf(self) -> gpd.GeoDataFrame:
        """Return aggregated shapes as a GeoDataFrame using set roadway geometry."""
        ref_nodes = self.road_net.nodes_df if self.road_net is not None else None
        return shapes_to_trip_shapes_gdf(self.feed.shapes, ref_nodes_df=ref_nodes)

    @property
    def shape_links_gdf(self) -> gpd.GeoDataFrame:
        """Return shape-links as a GeoDataFrame using set roadway geometry."""
        ref_nodes = self.road_net.nodes_df if self.road_net is not None else None
        return shapes_to_shape_links_gdf(self.feed.shapes, ref_nodes_df=ref_nodes)

    @property
    def stop_time_links_gdf(self) -> gpd.GeoDataFrame:
        """Return stop-time-links as a GeoDataFrame using set roadway geometry."""
        ref_nodes = self.road_net.nodes_df if self.road_net is not None else None
        return stop_times_to_stop_time_links_gdf(
            self.feed.stop_times, self.feed.stops, ref_nodes_df=ref_nodes
        )

    @property
    def stop_times_points_gdf(self) -> gpd.GeoDataFrame:
        """Return stop-time-points as a GeoDataFrame using set roadway geometry."""
        ref_nodes = self.road_net.nodes_df if self.road_net is not None else None

        return stop_times_to_stop_time_points_gdf(
            self.feed.stop_times, self.feed.stops, ref_nodes_df=ref_nodes
        )

    def get_selection(
        self,
        selection_dict: dict,
        overwrite: bool = False,
    ) -> TransitSelection:
        """Return selection if it already exists, otherwise performs selection.

        Will raise an error if no trips found.

        Args:
            selection_dict (dict): _description_
            overwrite: if True, will overwrite any previously cached searches. Defaults to False.

        Returns:
            Selection: Selection object
        """
        key = dict_to_hexkey(selection_dict)

        if (key not in self._selections) or overwrite:
            WranglerLogger.debug(f"Performing selection from key: {key}")
            self._selections[key] = TransitSelection(self, selection_dict)
        else:
            WranglerLogger.debug(f"Using cached selection from key: {key}")

        if not self._selections[key]:
            msg = f"No links or nodes found for selection dict: \n {selection_dict}"
            WranglerLogger.error(msg)
            raise TransitSelectionEmptyError(msg)
        return self._selections[key]

    def apply(self, project_card: ProjectCard | dict, **kwargs) -> TransitNetwork:
        """Wrapper method to apply a roadway project, returning a new TransitNetwork instance.

        Args:
            project_card: either a dictionary of the project card object or ProjectCard instance
            **kwargs: keyword arguments to pass to project application
        """
        if not (isinstance(project_card, ProjectCard | SubProject)):
            project_card = ProjectCard(project_card)

        if not project_card.valid:
            msg = f"Project card {project_card.project} not valid."
            WranglerLogger.error(msg)
            raise ProjectCardError(msg)

        if project_card._sub_projects:
            for sp in project_card._sub_projects:
                WranglerLogger.debug(f"- applying subproject: {sp.change_type}")
                self._apply_change(sp, **kwargs)
            return self
        return self._apply_change(project_card, **kwargs)

    def _apply_change(
        self,
        change: ProjectCard | SubProject,
        reference_road_net: RoadwayNetwork | None = None,
    ) -> TransitNetwork:
        """Apply a single change: a single-project project or a sub-project."""
        if not isinstance(change, SubProject):
            WranglerLogger.info(f"Applying Project to Transit Network: {change.project}")

        if change.change_type == "transit_property_change":
            return apply_transit_property_change(
                self,
                self.get_selection(change.transit_property_change["service"]),
                change.transit_property_change["property_changes"],
                project_name=change.project,
            )

        if change.change_type == "transit_routing_change":
            return apply_transit_routing_change(
                self,
                self.get_selection(change.transit_routing_change["service"]),
                change.transit_routing_change["routing"],
                reference_road_net=reference_road_net,
                project_name=change.project,
            )

        if change.change_type == "pycode":
            return apply_calculated_transit(self, change.pycode)

        if change.change_type == "transit_route_addition":
            return apply_transit_route_addition(
                self,
                change.transit_route_addition,
                reference_road_net=reference_road_net,
            )
        if change.change_type == "transit_service_deletion":
            return apply_transit_service_deletion(
                self,
                self.get_selection(change.transit_service_deletion["service"]),
                clean_shapes=change.transit_service_deletion.get("clean_shapes"),
                clean_routes=change.transit_service_deletion.get("clean_routes"),
            )
        msg = f"Not a currently valid transit project: {change}."
        WranglerLogger.error(msg)
        raise NotImplementedError(msg)

network_wrangler.transit.network.TransitNetwork.applied_projects property

applied_projects

List of projects applied to the network.

Note: This may or may not return a full accurate account of all the applied projects. For better project accounting, please leverage the scenario object.

network_wrangler.transit.network.TransitNetwork.consistent_with_road_net property

consistent_with_road_net

Indicate if road_net is consistent with transit network.

Will return True if road_net is None, but provide a warning.

Checks the modification version of when consistency was last evaluated. If transit network or roadway network has changed, will re-evaluate consistency and return the updated value.

Returns:

Type Description
bool

Boolean indicating if road_net is consistent with transit network.

network_wrangler.transit.network.TransitNetwork.feed property writable

feed

Feed associated with the transit network.

network_wrangler.transit.network.TransitNetwork.feed_hash property

feed_hash

Return the hash of the feed.

network_wrangler.transit.network.TransitNetwork.feed_path property

feed_path

Pass through property from Feed.

network_wrangler.transit.network.TransitNetwork.road_net property writable

road_net

Roadway network associated with the transit network.

shape_links_gdf

Return shape-links as a GeoDataFrame using set roadway geometry.

network_wrangler.transit.network.TransitNetwork.shapes_gdf property

shapes_gdf

Return aggregated shapes as a GeoDataFrame using set roadway geometry.

stop_time_links_gdf

Return stop-time-links as a GeoDataFrame using set roadway geometry.

network_wrangler.transit.network.TransitNetwork.stop_times_points_gdf property

stop_times_points_gdf

Return stop-time-points as a GeoDataFrame using set roadway geometry.

network_wrangler.transit.network.TransitNetwork.stops_gdf property

stops_gdf

Return stops as a GeoDataFrame using set roadway geometry.

network_wrangler.transit.network.TransitNetwork.__deepcopy__

__deepcopy__(memo)

Returns copied TransitNetwork instance with deep copy of Feed but not roadway net.

Source code in network_wrangler/transit/network.py
def __deepcopy__(self, memo):
    """Returns copied TransitNetwork instance with deep copy of Feed but not roadway net."""
    COPY_REF_NOT_VALUE = ["_road_net"]
    # Create a new, empty instance
    copied_net = self.__class__.__new__(self.__class__)
    # Return the new TransitNetwork instance
    attribute_dict = vars(self)

    # Copy the attributes to the new instance
    for attr_name, attr_value in attribute_dict.items():
        # WranglerLogger.debug(f"Copying {attr_name}")
        if attr_name in COPY_REF_NOT_VALUE:
            # If the attribute is in the COPY_REF_NOT_VALUE list, assign the reference
            setattr(copied_net, attr_name, attr_value)
        else:
            # WranglerLogger.debug(f"making deep copy: {attr_name}")
            # For other attributes, perform a deep copy
            setattr(copied_net, attr_name, copy.deepcopy(attr_value, memo))

    return copied_net

network_wrangler.transit.network.TransitNetwork.__init__

__init__(feed, config=DefaultConfig)

Constructor for TransitNetwork.

Parameters:

Name Type Description Default
feed Feed

Feed object representing the transit network gtfs tables

required
config WranglerConfig

WranglerConfig object. Defaults to DefaultConfig.

DefaultConfig
Source code in network_wrangler/transit/network.py
def __init__(self, feed: Feed, config: WranglerConfig = DefaultConfig) -> None:
    """Constructor for TransitNetwork.

    Args:
        feed: Feed object representing the transit network gtfs tables
        config: WranglerConfig object. Defaults to DefaultConfig.
    """
    WranglerLogger.debug("Creating new TransitNetwork.")

    self._road_net: RoadwayNetwork | None = None
    self.feed: Feed = feed
    self.graph: nx.MultiDiGraph = None
    self.config: WranglerConfig = config
    # initialize
    self._consistent_with_road_net = False

    # cached selections
    self._selections: dict[str, TransitSelection] = {}

network_wrangler.transit.network.TransitNetwork.apply

apply(project_card, **kwargs)

Wrapper method to apply a roadway project, returning a new TransitNetwork instance.

Parameters:

Name Type Description Default
project_card ProjectCard | dict

either a dictionary of the project card object or ProjectCard instance

required
**kwargs

keyword arguments to pass to project application

{}
Source code in network_wrangler/transit/network.py
def apply(self, project_card: ProjectCard | dict, **kwargs) -> TransitNetwork:
    """Wrapper method to apply a roadway project, returning a new TransitNetwork instance.

    Args:
        project_card: either a dictionary of the project card object or ProjectCard instance
        **kwargs: keyword arguments to pass to project application
    """
    if not (isinstance(project_card, ProjectCard | SubProject)):
        project_card = ProjectCard(project_card)

    if not project_card.valid:
        msg = f"Project card {project_card.project} not valid."
        WranglerLogger.error(msg)
        raise ProjectCardError(msg)

    if project_card._sub_projects:
        for sp in project_card._sub_projects:
            WranglerLogger.debug(f"- applying subproject: {sp.change_type}")
            self._apply_change(sp, **kwargs)
        return self
    return self._apply_change(project_card, **kwargs)

network_wrangler.transit.network.TransitNetwork.deepcopy

deepcopy()

Returns copied TransitNetwork instance with deep copy of Feed but not roadway net.

Source code in network_wrangler/transit/network.py
def deepcopy(self):
    """Returns copied TransitNetwork instance with deep copy of Feed but not roadway net."""
    return copy.deepcopy(self)

network_wrangler.transit.network.TransitNetwork.get_selection

get_selection(selection_dict, overwrite=False)

Return selection if it already exists, otherwise performs selection.

Will raise an error if no trips found.

Parameters:

Name Type Description Default
selection_dict dict

description

required
overwrite bool

if True, will overwrite any previously cached searches. Defaults to False.

False

Returns:

Name Type Description
Selection TransitSelection

Selection object

Source code in network_wrangler/transit/network.py
def get_selection(
    self,
    selection_dict: dict,
    overwrite: bool = False,
) -> TransitSelection:
    """Return selection if it already exists, otherwise performs selection.

    Will raise an error if no trips found.

    Args:
        selection_dict (dict): _description_
        overwrite: if True, will overwrite any previously cached searches. Defaults to False.

    Returns:
        Selection: Selection object
    """
    key = dict_to_hexkey(selection_dict)

    if (key not in self._selections) or overwrite:
        WranglerLogger.debug(f"Performing selection from key: {key}")
        self._selections[key] = TransitSelection(self, selection_dict)
    else:
        WranglerLogger.debug(f"Using cached selection from key: {key}")

    if not self._selections[key]:
        msg = f"No links or nodes found for selection dict: \n {selection_dict}"
        WranglerLogger.error(msg)
        raise TransitSelectionEmptyError(msg)
    return self._selections[key]

network_wrangler.models._base.db.DbForeignKeys module-attribute

DbForeignKeys = dict[str, TableForeignKeys]

Mapping of tables that have fields that other tables use as fks.

{ <table>:{<field>:[(<table using FK>,<field using fk>)]} }

Example

{“stops”: {“stop_id”: [ (“stops”, “parent_station”), (“stop_times”, “stop_id”) ]} }

network_wrangler.models._base.db.TableForeignKeys module-attribute

TableForeignKeys = dict[str, tuple[str, str]]

Dict of each table’s foreign keys.

{ <table>:{<field>:[<fk_table>,<fk_field>]} }

Example

{“stops”: {“parent_station”: (“stops”, “stop_id”)} “stop_times”: {“stop_id”: (“stops”, “stop_id”)} {“trip_id”: (“trips”, “trip_id”)} }

network_wrangler.models._base.db.TablePrimaryKeys module-attribute

TablePrimaryKeys = list[str]

TableForeignKeys is a dictionary of foreign keys for a single table.

Uses the form

{:[,]}

Example

{“parent_station”: (“stops”, “stop_id”)}

network_wrangler.models._base.db.DBModelMixin

An mixin class for interrelated pandera DataFrameModel tables.

Contains a bunch of convenience methods and overrides the dunder methods deepcopy and eq.

Methods:

Name Description
hash

hash of tables

deepcopy

deepcopy of tables which references a custom deepcopy

get_table

retrieve table by name

table_names_with_field

returns tables in table_names with field name

Attr

Where metadata variable _fk = {:[,]}

e.g.: _fk = {"parent_station": ["stops", "stop_id"]}

Source code in network_wrangler/models/_base/db.py
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 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
class DBModelMixin:
    """An mixin class for interrelated pandera DataFrameModel tables.

    Contains a bunch of convenience methods and overrides the dunder methods
        __deepcopy__ and __eq__.

    Methods:
        hash: hash of tables
        deepcopy: deepcopy of tables which references a custom __deepcopy__
        get_table: retrieve table by name
        table_names_with_field: returns tables in `table_names` with field name

    Attr:
        table_names: list of dataframe table names that are required as part of this "db"
            schema.
        optional_table_names: list of optional table names that will be added to `table_names` iff
            they are found.
        hash: creates a hash of tables found in `table_names` to track if they change.
        modification_version: counter that increments when tables are modified. Used for
            efficient change detection without computing expensive hashes.
        tables: dataframes corresponding to each table_name in `table_names`
        tables_dict: mapping of `<table_name>:<table>` dataframe
        _table_models: mapping of `<table_name>:<DataFrameModel>` to use for validation when
            `__setattr__` is called.
        _converters: mapping of `<table_name>:<converter_method>` where converter method should
            have a function signature of `(<table>, self.**kwargs)` .  Called on `__setattr__` if
            initial validation fails.

    Where metadata variable _fk = {<table_field>:[<fk table>,<fk field>]}

    e.g.: `_fk = {"parent_station": ["stops", "stop_id"]}`

    """

    # list of optional tables which are added to table_names if they are found.
    optional_table_names: ClassVar[list[str]] = []

    # list of interrelated tables.
    table_names: ClassVar[list[str]] = []

    # mapping of which Pandera DataFrameModel to validate the table to.
    _table_models: ClassVar[dict[str, DataFrameModel]] = {}

    # mapping of <table_name>:<conversion method> to use iff df validation fails.
    _converters: ClassVar[dict[str, Callable]] = {}

    # Instance attribute for tracking modifications (initialized in __setattr__)
    _modification_version: int = 0

    def _mark_modified(self) -> None:
        """Mark the database as modified by incrementing the modification version.

        This is called automatically when tables are modified via __setattr__.
        """
        # Use object.__setattr__ to avoid recursion
        current = getattr(self, "_modification_version", 0)
        object.__setattr__(self, "_modification_version", current + 1)

    @property
    def modification_version(self) -> int:
        """Return the current modification version.

        This counter increments each time a table is modified and can be used
        for efficient change detection without computing expensive hashes.
        """
        return getattr(self, "_modification_version", 0)

    def __setattr__(self, key, value):
        """Override the default setattr behavior to handle DataFrame validation.

        Note: this is NOT called when a dataframe is mutated in place!

        Args:
            key (str): The attribute name.
            value: The value to be assigned to the attribute.

        Raises:
            SchemaErrors: If the DataFrame does not conform to the schema.
            ForeignKeyError: If doesn't validate to foreign key.
        """
        if isinstance(value, pd.DataFrame):
            WranglerLogger.debug(f"Validating + coercing value to {key}")
            df = self.validate_coerce_table(key, value)
            super().__setattr__(key, df)
            # Mark as modified when a table is updated
            if key in self.table_names or key in self.optional_table_names:
                self._mark_modified()
        else:
            super().__setattr__(key, value)

    def validate_coerce_table(self, table_name: str, table: pd.DataFrame) -> pd.DataFrame:
        if table_name not in self._table_models:
            return table
        table_model = self._table_models[table_name]
        converter = self._converters.get(table_name)
        try:
            validated_df = validate_df_to_model(table, table_model)
        except SchemaErrors as e:
            if not converter:
                raise e
            WranglerLogger.debug(
                f"Initial validation failed as {table_name}. \
                                Attempting to convert using: {converter}"
            )
            # Note that some converters may have dependency on other attributes being set first
            converted_df = converter(table, **self.__dict__)
            validated_df = validate_df_to_model(converted_df, table_model)

        # Do this in both directions so that ordering of tables being added doesn't matter.
        self.check_table_fks(table_name, table=validated_df)
        self.check_referenced_fks(table_name, table=validated_df)
        return validated_df

    def initialize_tables(self, **kwargs):
        """Initializes the tables for the database.

        Args:
            **kwargs: Keyword arguments representing the tables to be initialized.

        Raises:
            RequiredTableError: If any required tables are missing in the initialization.
        """
        # Flag missing required tables
        _missing_tables = [t for t in self.table_names if t not in kwargs]
        if _missing_tables:
            msg = f"Missing required tables: {_missing_tables}"
            raise RequiredTableError(msg)

        # Add provided optional tables
        _opt_tables = [k for k in kwargs if k in self.optional_table_names]
        self.table_names += _opt_tables

        # Set tables in order
        for table in self.table_names:
            WranglerLogger.info(f"Initializing {table}")
            self.__setattr__(table, kwargs[table])

    @classmethod
    def fks(cls) -> DbForeignKeys:
        """Return the fk field constraints as `{ <table>:{<field>:[<fk_table>,<fk_field>]} }`."""
        fk_fields = {}
        for table_name, table_model in cls._table_models.items():
            config = table_model.Config
            if not hasattr(config, "_fk"):
                continue
            fk_fields[table_name] = config._fk
        return fk_fields

    @classmethod
    def fields_as_fks(cls) -> DbForeignKeyUsage:
        """Returns mapping of tables that have fields that other tables use as fks.

        `{ <table>:{<field>:[(<table using FK>,<field using fk>)]} }`

        Useful for knowing if you should check FK validation when changing a field value.
        """
        pks_as_fks: defaultdict = defaultdict(lambda: defaultdict(list))
        for t, field_fk in cls.fks().items():
            for f, fk in field_fk.items():
                fk_table, fk_field = fk
                pks_as_fks[fk_table][fk_field].append((t, f))
        return {k: dict(v) for k, v in pks_as_fks.items()}

    def check_referenced_fk(
        self, pk_table_name: str, pk_field: str, pk_table: pd.DataFrame | None = None
    ) -> bool:
        """True if table.field has the values referenced in any table referencing fields as fk.

        For example. If routes.route_id is referenced in trips table, we need to check that
        if a route_id is deleted, it isn't referenced in trips.route_id.
        """
        msg = f"Checking tables which referenced {pk_table_name}.{pk_field} as an FK"
        # WranglerLogger.debug(msg)
        if pk_table is None:
            pk_table = self.get_table(pk_table_name)

        if pk_field not in pk_table:
            WranglerLogger.warning(
                f"Foreign key value {pk_field} not in {pk_table_name} - \
                 skipping fk validation"
            )
            return True

        fields_as_fks: DbForeignKeyUsage = self.fields_as_fks()

        if pk_table_name not in fields_as_fks:
            return True
        if pk_field not in fields_as_fks[pk_table_name]:
            return True

        all_valid = True

        for ref_table_name, ref_field in fields_as_fks[pk_table_name][pk_field]:
            if ref_table_name not in self.table_names:
                WranglerLogger.debug(
                    f"Referencing table {ref_table_name} not in self.table_names - "
                    + f"skipping fk validation."
                )
                continue

            try:
                ref_table = self.get_table(ref_table_name)
            except RequiredTableError:
                WranglerLogger.debug(
                    f"Referencing table {ref_table_name} not yet set in "
                    + f"{type(self)} - skipping fk validation."
                )
                continue

            if ref_field not in ref_table:
                WranglerLogger.debug(
                    f"Referencing field {ref_field} not in {ref_table_name} - "
                    + f"skipping fk validation."
                )
                continue

            valid, _missing = fk_in_pk(pk_table[pk_field], ref_table[ref_field])
            all_valid = all_valid and valid
            if _missing:
                WranglerLogger.error(
                    f"Following values missing from {pk_table_name}.{pk_field} that "
                    + f"are referenced by {_missing}:\n{ref_table}"
                )
        return all_valid

    def check_referenced_fks(self, table_name: str, table: pd.DataFrame | None = None) -> bool:
        """True if this table has the values referenced in any table referencing fields as fk.

        For example. If routes.route_id is referenced in trips table, we need to check that
        if a route_id is deleted, it isn't referenced in trips.route_id.
        """
        # WranglerLogger.debug(f"Checking referenced foreign keys for {table_name}")
        all_valid = True
        if table is None:
            table = self.get_table(table_name)
        all_valid = True
        for field in self.fields_as_fks().get(table_name, {}):
            valid = self.check_referenced_fk(table_name, field, pk_table=table)
            all_valid = valid and all_valid
        return all_valid

    def check_table_fks(
        self, table_name: str, table: pd.DataFrame | None = None, raise_error: bool = True
    ) -> bool:
        """Return True if the foreign key fields in table have valid references.

        Note: will return true and give a warning if the specified foreign key table doesn't exist.
        """
        # WranglerLogger.debug(f"Checking foreign keys for {table_name}")
        fks = self.fks()
        if table_name not in fks:
            return True
        if table is None:
            table = self.get_table(table_name)
        all_valid = True
        for field, fk in fks[table_name].items():
            # WranglerLogger.debug(f"Checking {table_name}.{field} foreign key")
            pkref_table_name, pkref_field = fk
            # WranglerLogger.debug(f"Looking for PK in {pkref_table_name}.{pkref_field}.")
            if field not in table:
                WranglerLogger.warning(
                    f"Foreign key value {field} not in {table_name} - " + f"skipping validation"
                )
                continue

            if pkref_table_name not in self.table_names:
                WranglerLogger.debug(
                    f"PK table {pkref_table_name} for specified FK "
                    + f"{table_name}.{field} not in table list - skipping validation."
                )
                continue
            try:
                pkref_table = self.get_table(pkref_table_name)
            except RequiredTableError:
                WranglerLogger.debug(
                    f"PK table {pkref_table_name} for specified FK "
                    + f"{table_name}.{field} not in {type(self)} - "
                    + f"skipping validation."
                )
                continue
            if pkref_field not in pkref_table:
                WranglerLogger.error(
                    f"!!! {pkref_table_name} missing {pkref_field} field used as FK "
                    + f"ref in {table_name}.{field}."
                )
                all_valid = False
                continue
            if len(pkref_table) < SMALL_RECS:
                pass
                # WranglerLogger.debug(f"PK values:\n{pkref_table[pkref_field]}.")
            # WranglerLogger.debug(f"Checking {table_name}.{field} foreign key")
            valid, missing = fk_in_pk(pkref_table[pkref_field], table[field])
            if missing:
                WranglerLogger.error(
                    f"!!! {pkref_table_name}.{pkref_field} missing values used as FK "
                    + f"in {table_name}.{field}: \n{missing}"
                )
            all_valid = valid and all_valid

        if not all_valid:
            if raise_error:
                msg = f"FK fields/ values referenced in {table_name} missing."
                raise ForeignKeyValueError(msg)
            return False
        return True

    def check_fks(self) -> bool:
        """Check all FKs in set of tables."""
        all_valid = True
        for table_name in self.table_names:
            valid = self.check_table_fks(
                table_name, self.tables_dict[table_name], raise_error=False
            )
            all_valid = valid and all_valid
        return all_valid

    @property
    def tables(self) -> list[DataFrameModel]:
        return [self.__dict__[t] for t in self.table_names]

    @property
    def tables_dict(self) -> dict[str, DataFrameModel]:
        num_records = [len(self.get_table(t)) for t in self.table_names]
        return pd.DataFrame({"Table": self.table_names, "Records": num_records})

    @property
    def describe_df(self) -> pd.DataFrame:
        return pd.DataFrame({t: len(self.get_table(t)) for t in self.table_names})

    def get_table(self, table_name: str) -> pd.DataFrame:
        """Get table by name."""
        if table_name not in self.table_names:
            msg = f"{table_name} table not in db."
            raise ValueError(msg)
        if table_name not in self.__dict__:
            msg = f"Required table not set yet: {table_name}"
            raise RequiredTableError(msg)
        return self.__dict__[table_name]

    def table_names_with_field(self, field: str) -> list[str]:
        """Returns tables in the class instance which contain the field."""
        return [t for t in self.table_names if field in self.get_table(t).columns]

    @property
    def hash(self) -> str:
        """A hash representing the contents of the tables in self.table_names.

        Note: This is an expensive operation. For change detection, prefer using
        modification_version which is much faster.
        """
        _table_hashes = [self.get_table(t).df_hash() for t in self.table_names]
        _value = str.encode("-".join(_table_hashes))

        _hash = hashlib.sha256(_value).hexdigest()
        return _hash

    def __eq__(self, other):
        """Override the default Equals behavior."""
        if isinstance(other, self.__class__):
            return self.hash == other.hash
        return False

    def __deepcopy__(self, memo):
        """Custom implementation of __deepcopy__ method.

        This method is called by copy.deepcopy() to create a deep copy of the object.

        Args:
            memo (dict): Dictionary to track objects already copied during deepcopy.

        Returns:
            Feed: A deep copy of the db object.
        """
        # Create a new, empty instance of the Feed class
        new_instance = self.__class__.__new__(self.__class__)

        # Copy all attributes to the new instance
        for attr_name, attr_value in self.__dict__.items():
            # Handle pandera DataFrameModel objects specially
            if (
                hasattr(attr_value, "__class__")
                and hasattr(attr_value.__class__, "__name__")
                and "DataFrameModel" in attr_value.__class__.__name__
            ):
                # For pandera DataFrameModel objects, copy the underlying DataFrame and recreate the model
                # This avoids the timestamp corruption issue with copy.deepcopy()
                try:
                    # Get the underlying DataFrame
                    if hasattr(attr_value, "_obj"):
                        df_copy = attr_value._obj.copy(deep=True)
                    elif hasattr(attr_value, "data"):
                        df_copy = attr_value.data.copy(deep=True)
                    else:
                        # For newer pandera versions, try direct access
                        df_copy = attr_value.copy(deep=True)

                    # Recreate the DataFrameModel object with the copied DataFrame
                    new_table = attr_value.__class__(df_copy)

                    setattr(new_instance, attr_name, new_table)
                except Exception as e:
                    # Fallback to regular deep copy if the above fails
                    setattr(new_instance, attr_name, copy.deepcopy(attr_value, memo))
            elif isinstance(attr_value, pd.DataFrame):
                # For plain pandas DataFrames, use deep copy
                setattr(new_instance, attr_name, attr_value.copy(deep=True))
            else:
                # For all other objects, use regular deep copy
                setattr(new_instance, attr_name, copy.deepcopy(attr_value, memo))

        return new_instance

    def deepcopy(self):
        """Convenience method to exceute deep copy of instance."""
        return copy.deepcopy(self)

    def __hash__(self):
        """Hash based on the hashes of the tables in table_names."""
        return hash(tuple((name, self.get_table(name).to_csv()) for name in self.table_names))

network_wrangler.models._base.db.DBModelMixin.hash property

hash

A hash representing the contents of the tables in self.table_names.

Note: This is an expensive operation. For change detection, prefer using modification_version which is much faster.

network_wrangler.models._base.db.DBModelMixin.modification_version property

modification_version

Return the current modification version.

This counter increments each time a table is modified and can be used for efficient change detection without computing expensive hashes.

network_wrangler.models._base.db.DBModelMixin.__deepcopy__

__deepcopy__(memo)

Custom implementation of deepcopy method.

This method is called by copy.deepcopy() to create a deep copy of the object.

Parameters:

Name Type Description Default
memo dict

Dictionary to track objects already copied during deepcopy.

required

Returns:

Name Type Description
Feed

A deep copy of the db object.

Source code in network_wrangler/models/_base/db.py
def __deepcopy__(self, memo):
    """Custom implementation of __deepcopy__ method.

    This method is called by copy.deepcopy() to create a deep copy of the object.

    Args:
        memo (dict): Dictionary to track objects already copied during deepcopy.

    Returns:
        Feed: A deep copy of the db object.
    """
    # Create a new, empty instance of the Feed class
    new_instance = self.__class__.__new__(self.__class__)

    # Copy all attributes to the new instance
    for attr_name, attr_value in self.__dict__.items():
        # Handle pandera DataFrameModel objects specially
        if (
            hasattr(attr_value, "__class__")
            and hasattr(attr_value.__class__, "__name__")
            and "DataFrameModel" in attr_value.__class__.__name__
        ):
            # For pandera DataFrameModel objects, copy the underlying DataFrame and recreate the model
            # This avoids the timestamp corruption issue with copy.deepcopy()
            try:
                # Get the underlying DataFrame
                if hasattr(attr_value, "_obj"):
                    df_copy = attr_value._obj.copy(deep=True)
                elif hasattr(attr_value, "data"):
                    df_copy = attr_value.data.copy(deep=True)
                else:
                    # For newer pandera versions, try direct access
                    df_copy = attr_value.copy(deep=True)

                # Recreate the DataFrameModel object with the copied DataFrame
                new_table = attr_value.__class__(df_copy)

                setattr(new_instance, attr_name, new_table)
            except Exception as e:
                # Fallback to regular deep copy if the above fails
                setattr(new_instance, attr_name, copy.deepcopy(attr_value, memo))
        elif isinstance(attr_value, pd.DataFrame):
            # For plain pandas DataFrames, use deep copy
            setattr(new_instance, attr_name, attr_value.copy(deep=True))
        else:
            # For all other objects, use regular deep copy
            setattr(new_instance, attr_name, copy.deepcopy(attr_value, memo))

    return new_instance

network_wrangler.models._base.db.DBModelMixin.__eq__

__eq__(other)

Override the default Equals behavior.

Source code in network_wrangler/models/_base/db.py
def __eq__(self, other):
    """Override the default Equals behavior."""
    if isinstance(other, self.__class__):
        return self.hash == other.hash
    return False

network_wrangler.models._base.db.DBModelMixin.__hash__

__hash__()

Hash based on the hashes of the tables in table_names.

Source code in network_wrangler/models/_base/db.py
def __hash__(self):
    """Hash based on the hashes of the tables in table_names."""
    return hash(tuple((name, self.get_table(name).to_csv()) for name in self.table_names))

network_wrangler.models._base.db.DBModelMixin.__setattr__

__setattr__(key, value)

Override the default setattr behavior to handle DataFrame validation.

Note: this is NOT called when a dataframe is mutated in place!

Parameters:

Name Type Description Default
key str

The attribute name.

required
value

The value to be assigned to the attribute.

required

Raises:

Type Description
SchemaErrors

If the DataFrame does not conform to the schema.

ForeignKeyError

If doesn’t validate to foreign key.

Source code in network_wrangler/models/_base/db.py
def __setattr__(self, key, value):
    """Override the default setattr behavior to handle DataFrame validation.

    Note: this is NOT called when a dataframe is mutated in place!

    Args:
        key (str): The attribute name.
        value: The value to be assigned to the attribute.

    Raises:
        SchemaErrors: If the DataFrame does not conform to the schema.
        ForeignKeyError: If doesn't validate to foreign key.
    """
    if isinstance(value, pd.DataFrame):
        WranglerLogger.debug(f"Validating + coercing value to {key}")
        df = self.validate_coerce_table(key, value)
        super().__setattr__(key, df)
        # Mark as modified when a table is updated
        if key in self.table_names or key in self.optional_table_names:
            self._mark_modified()
    else:
        super().__setattr__(key, value)

network_wrangler.models._base.db.DBModelMixin.check_fks

check_fks()

Check all FKs in set of tables.

Source code in network_wrangler/models/_base/db.py
def check_fks(self) -> bool:
    """Check all FKs in set of tables."""
    all_valid = True
    for table_name in self.table_names:
        valid = self.check_table_fks(
            table_name, self.tables_dict[table_name], raise_error=False
        )
        all_valid = valid and all_valid
    return all_valid

network_wrangler.models._base.db.DBModelMixin.check_referenced_fk

check_referenced_fk(pk_table_name, pk_field, pk_table=None)

True if table.field has the values referenced in any table referencing fields as fk.

For example. If routes.route_id is referenced in trips table, we need to check that if a route_id is deleted, it isn’t referenced in trips.route_id.

Source code in network_wrangler/models/_base/db.py
def check_referenced_fk(
    self, pk_table_name: str, pk_field: str, pk_table: pd.DataFrame | None = None
) -> bool:
    """True if table.field has the values referenced in any table referencing fields as fk.

    For example. If routes.route_id is referenced in trips table, we need to check that
    if a route_id is deleted, it isn't referenced in trips.route_id.
    """
    msg = f"Checking tables which referenced {pk_table_name}.{pk_field} as an FK"
    # WranglerLogger.debug(msg)
    if pk_table is None:
        pk_table = self.get_table(pk_table_name)

    if pk_field not in pk_table:
        WranglerLogger.warning(
            f"Foreign key value {pk_field} not in {pk_table_name} - \
             skipping fk validation"
        )
        return True

    fields_as_fks: DbForeignKeyUsage = self.fields_as_fks()

    if pk_table_name not in fields_as_fks:
        return True
    if pk_field not in fields_as_fks[pk_table_name]:
        return True

    all_valid = True

    for ref_table_name, ref_field in fields_as_fks[pk_table_name][pk_field]:
        if ref_table_name not in self.table_names:
            WranglerLogger.debug(
                f"Referencing table {ref_table_name} not in self.table_names - "
                + f"skipping fk validation."
            )
            continue

        try:
            ref_table = self.get_table(ref_table_name)
        except RequiredTableError:
            WranglerLogger.debug(
                f"Referencing table {ref_table_name} not yet set in "
                + f"{type(self)} - skipping fk validation."
            )
            continue

        if ref_field not in ref_table:
            WranglerLogger.debug(
                f"Referencing field {ref_field} not in {ref_table_name} - "
                + f"skipping fk validation."
            )
            continue

        valid, _missing = fk_in_pk(pk_table[pk_field], ref_table[ref_field])
        all_valid = all_valid and valid
        if _missing:
            WranglerLogger.error(
                f"Following values missing from {pk_table_name}.{pk_field} that "
                + f"are referenced by {_missing}:\n{ref_table}"
            )
    return all_valid

network_wrangler.models._base.db.DBModelMixin.check_referenced_fks

check_referenced_fks(table_name, table=None)

True if this table has the values referenced in any table referencing fields as fk.

For example. If routes.route_id is referenced in trips table, we need to check that if a route_id is deleted, it isn’t referenced in trips.route_id.

Source code in network_wrangler/models/_base/db.py
def check_referenced_fks(self, table_name: str, table: pd.DataFrame | None = None) -> bool:
    """True if this table has the values referenced in any table referencing fields as fk.

    For example. If routes.route_id is referenced in trips table, we need to check that
    if a route_id is deleted, it isn't referenced in trips.route_id.
    """
    # WranglerLogger.debug(f"Checking referenced foreign keys for {table_name}")
    all_valid = True
    if table is None:
        table = self.get_table(table_name)
    all_valid = True
    for field in self.fields_as_fks().get(table_name, {}):
        valid = self.check_referenced_fk(table_name, field, pk_table=table)
        all_valid = valid and all_valid
    return all_valid

network_wrangler.models._base.db.DBModelMixin.check_table_fks

check_table_fks(table_name, table=None, raise_error=True)

Return True if the foreign key fields in table have valid references.

Note: will return true and give a warning if the specified foreign key table doesn’t exist.

Source code in network_wrangler/models/_base/db.py
def check_table_fks(
    self, table_name: str, table: pd.DataFrame | None = None, raise_error: bool = True
) -> bool:
    """Return True if the foreign key fields in table have valid references.

    Note: will return true and give a warning if the specified foreign key table doesn't exist.
    """
    # WranglerLogger.debug(f"Checking foreign keys for {table_name}")
    fks = self.fks()
    if table_name not in fks:
        return True
    if table is None:
        table = self.get_table(table_name)
    all_valid = True
    for field, fk in fks[table_name].items():
        # WranglerLogger.debug(f"Checking {table_name}.{field} foreign key")
        pkref_table_name, pkref_field = fk
        # WranglerLogger.debug(f"Looking for PK in {pkref_table_name}.{pkref_field}.")
        if field not in table:
            WranglerLogger.warning(
                f"Foreign key value {field} not in {table_name} - " + f"skipping validation"
            )
            continue

        if pkref_table_name not in self.table_names:
            WranglerLogger.debug(
                f"PK table {pkref_table_name} for specified FK "
                + f"{table_name}.{field} not in table list - skipping validation."
            )
            continue
        try:
            pkref_table = self.get_table(pkref_table_name)
        except RequiredTableError:
            WranglerLogger.debug(
                f"PK table {pkref_table_name} for specified FK "
                + f"{table_name}.{field} not in {type(self)} - "
                + f"skipping validation."
            )
            continue
        if pkref_field not in pkref_table:
            WranglerLogger.error(
                f"!!! {pkref_table_name} missing {pkref_field} field used as FK "
                + f"ref in {table_name}.{field}."
            )
            all_valid = False
            continue
        if len(pkref_table) < SMALL_RECS:
            pass
            # WranglerLogger.debug(f"PK values:\n{pkref_table[pkref_field]}.")
        # WranglerLogger.debug(f"Checking {table_name}.{field} foreign key")
        valid, missing = fk_in_pk(pkref_table[pkref_field], table[field])
        if missing:
            WranglerLogger.error(
                f"!!! {pkref_table_name}.{pkref_field} missing values used as FK "
                + f"in {table_name}.{field}: \n{missing}"
            )
        all_valid = valid and all_valid

    if not all_valid:
        if raise_error:
            msg = f"FK fields/ values referenced in {table_name} missing."
            raise ForeignKeyValueError(msg)
        return False
    return True

network_wrangler.models._base.db.DBModelMixin.deepcopy

deepcopy()

Convenience method to exceute deep copy of instance.

Source code in network_wrangler/models/_base/db.py
def deepcopy(self):
    """Convenience method to exceute deep copy of instance."""
    return copy.deepcopy(self)

network_wrangler.models._base.db.DBModelMixin.fields_as_fks classmethod

fields_as_fks()

Returns mapping of tables that have fields that other tables use as fks.

{ <table>:{<field>:[(<table using FK>,<field using fk>)]} }

Useful for knowing if you should check FK validation when changing a field value.

Source code in network_wrangler/models/_base/db.py
@classmethod
def fields_as_fks(cls) -> DbForeignKeyUsage:
    """Returns mapping of tables that have fields that other tables use as fks.

    `{ <table>:{<field>:[(<table using FK>,<field using fk>)]} }`

    Useful for knowing if you should check FK validation when changing a field value.
    """
    pks_as_fks: defaultdict = defaultdict(lambda: defaultdict(list))
    for t, field_fk in cls.fks().items():
        for f, fk in field_fk.items():
            fk_table, fk_field = fk
            pks_as_fks[fk_table][fk_field].append((t, f))
    return {k: dict(v) for k, v in pks_as_fks.items()}

network_wrangler.models._base.db.DBModelMixin.fks classmethod

fks()

Return the fk field constraints as { <table>:{<field>:[<fk_table>,<fk_field>]} }.

Source code in network_wrangler/models/_base/db.py
@classmethod
def fks(cls) -> DbForeignKeys:
    """Return the fk field constraints as `{ <table>:{<field>:[<fk_table>,<fk_field>]} }`."""
    fk_fields = {}
    for table_name, table_model in cls._table_models.items():
        config = table_model.Config
        if not hasattr(config, "_fk"):
            continue
        fk_fields[table_name] = config._fk
    return fk_fields

network_wrangler.models._base.db.DBModelMixin.get_table

get_table(table_name)

Get table by name.

Source code in network_wrangler/models/_base/db.py
def get_table(self, table_name: str) -> pd.DataFrame:
    """Get table by name."""
    if table_name not in self.table_names:
        msg = f"{table_name} table not in db."
        raise ValueError(msg)
    if table_name not in self.__dict__:
        msg = f"Required table not set yet: {table_name}"
        raise RequiredTableError(msg)
    return self.__dict__[table_name]

network_wrangler.models._base.db.DBModelMixin.initialize_tables

initialize_tables(**kwargs)

Initializes the tables for the database.

Parameters:

Name Type Description Default
**kwargs

Keyword arguments representing the tables to be initialized.

{}

Raises:

Type Description
RequiredTableError

If any required tables are missing in the initialization.

Source code in network_wrangler/models/_base/db.py
def initialize_tables(self, **kwargs):
    """Initializes the tables for the database.

    Args:
        **kwargs: Keyword arguments representing the tables to be initialized.

    Raises:
        RequiredTableError: If any required tables are missing in the initialization.
    """
    # Flag missing required tables
    _missing_tables = [t for t in self.table_names if t not in kwargs]
    if _missing_tables:
        msg = f"Missing required tables: {_missing_tables}"
        raise RequiredTableError(msg)

    # Add provided optional tables
    _opt_tables = [k for k in kwargs if k in self.optional_table_names]
    self.table_names += _opt_tables

    # Set tables in order
    for table in self.table_names:
        WranglerLogger.info(f"Initializing {table}")
        self.__setattr__(table, kwargs[table])

network_wrangler.models._base.db.DBModelMixin.table_names_with_field

table_names_with_field(field)

Returns tables in the class instance which contain the field.

Source code in network_wrangler/models/_base/db.py
def table_names_with_field(self, field: str) -> list[str]:
    """Returns tables in the class instance which contain the field."""
    return [t for t in self.table_names if field in self.get_table(t).columns]

Configuration

Classes and utilities for configuring Network Wrangler behavior:

Configuration for parameters for Network Wrangler.

Users can change a handful of parameters which control the way Wrangler runs. These parameters can be saved as a wrangler config file which can be read in repeatedly to make sure the same parameters are used each time.

Usage

At runtime, you can specify configurable parameters at the scenario level which will then also be assigned and accessible to the roadway and transit networks.

create_scenario(...config = myconfig)

Or if you are not using Scenario functionality, you can specify the config when you read in a RoadwayNetwork.

load_roadway_from_dir(**roadway, config=myconfig)
load_transit(**transit, config=myconfig)

my_config can be a:

  • Path to a config file in yaml/toml/json (recommended),
  • List of paths to config files (in case you want to split up various sub-configurations)
  • Dictionary which is in the same structure of a config file, or
  • A WranglerConfig() instance.

If not provided, Wrangler will use reasonable defaults.

Default Wrangler Configuration Values

If not explicitly provided, the following default values are used:

IDS:
    TRANSIT_SHAPE_ID_METHOD: scalar
    TRANSIT_SHAPE_ID_SCALAR: 1000000
    ROAD_SHAPE_ID_METHOD: scalar
    ROAD_SHAPE_ID_SCALAR: 1000
    ML_LINK_ID_METHOD: range
    ML_LINK_ID_RANGE: (950000, 999999)
    ML_LINK_ID_SCALAR: 15000
    ML_NODE_ID_METHOD: range
    ML_NODE_ID_RANGE: (950000, 999999)
    ML_NODE_ID_SCALAR: 15000
EDITS:
    EXISTING_VALUE_CONFLIC: warn
    OVERWRITE_SCOPED: conflicting
MODEL_ROADWAY:
    ML_OFFSET_METERS: int = -10
    ADDITIONAL_COPY_FROM_GP_TO_ML: []
    ADDITIONAL_COPY_TO_ACCESS_EGRESS: []
CPU:
    EST_PD_READ_SPEED:
        csv: 0.03
        parquet: 0.005
        geojson: 0.03
        json: 0.15
        txt: 0.04
Extended usage

Load the default configuration:

from network_wrangler.configs import DefaultConfig

Access the configuration:

from network_wrangler.configs import DefaultConfig
DefaultConfig.MODEL_ROADWAY.ML_OFFSET_METERS
>> -10

Modify the default configuration in-line:

from network_wrangler.configs import DefaultConfig

DefaultConfig.MODEL_ROADWAY.ML_OFFSET_METERS = 20

Load a configuration from a file:

from network_wrangler.configs import load_wrangler_config

config = load_wrangler_config("path/to/config.yaml")

Set a configuration value:

config.MODEL_ROADWAY.ML_OFFSET_METERS = 10

network_wrangler.configs.wrangler.CpuConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.wrangler.CpuConfig[CpuConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.wrangler.CpuConfig
                


              click network_wrangler.configs.wrangler.CpuConfig href "" "network_wrangler.configs.wrangler.CpuConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

CPU Configuration - Will not change any outcomes.

Attributes:

Name Type Description
EST_PD_READ_SPEED dict[str, float]

Read sec / MB - WILL DEPEND ON SPECIFIC COMPUTER

Source code in network_wrangler/configs/wrangler.py
@dataclass
class CpuConfig(ConfigItem):
    """CPU Configuration -  Will not change any outcomes.

    Attributes:
        EST_PD_READ_SPEED: Read sec / MB - WILL DEPEND ON SPECIFIC COMPUTER
    """

    EST_PD_READ_SPEED: dict[str, float] = Field(
        default_factory=lambda: {
            "csv": 0.03,
            "parquet": 0.005,
            "geojson": 0.03,
            "json": 0.15,
            "txt": 0.04,
        }
    )

network_wrangler.configs.wrangler.EditsConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.wrangler.EditsConfig[EditsConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.wrangler.EditsConfig
                


              click network_wrangler.configs.wrangler.EditsConfig href "" "network_wrangler.configs.wrangler.EditsConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Configuration for Edits.

Attributes:

Name Type Description
EXISTING_VALUE_CONFLICT Literal['warn', 'error', 'skip']

Only used if ‘existing’ provided in project card and existing doesn’t match the existing network value. One of error, warn, or skip. error will raise an error, warn will warn the user, and skip will skip the change for that specific property (note it will still apply any remaining property changes). Defaults to warn. Can be overridden by setting existing_value_conflict in a roadway_property_change project card.

OVERWRITE_SCOPED Literal['conflicting', 'all', 'error']

How to handle conflicts with existing values. Should be one of “conflicting”, “all”, or False. “conflicting” will only overwrite values where the scope only partially overlaps with the existing value. “all” will overwrite all the scoped values. “error” will error if there is any overlap. Default is “conflicting”. Can be changed at the project-level by setting overwrite_scoped in a roadway_property_change project card.

Source code in network_wrangler/configs/wrangler.py
@dataclass
class EditsConfig(ConfigItem):
    """Configuration for Edits.

    Attributes:
        EXISTING_VALUE_CONFLICT: Only used if 'existing' provided in project card and
            `existing` doesn't match the existing network value. One of `error`, `warn`, or `skip`.
            `error` will raise an error, `warn` will warn the user, and `skip` will skip the change
            for that specific property (note it will still apply any remaining property changes).
            Defaults to `warn`. Can be overridden by setting `existing_value_conflict` in
            a `roadway_property_change` project card.

        OVERWRITE_SCOPED: How to handle conflicts with existing values.
            Should be one of "conflicting", "all", or False.
            "conflicting" will only overwrite values where the scope only partially overlaps with
            the existing value. "all" will overwrite all the scoped values. "error" will error if
            there is any overlap. Default is "conflicting". Can be changed at the project-level
            by setting `overwrite_scoped` in a `roadway_property_change` project card.
    """

    EXISTING_VALUE_CONFLICT: Literal["warn", "error", "skip"] = "warn"
    OVERWRITE_SCOPED: Literal["conflicting", "all", "error"] = "conflicting"

network_wrangler.configs.wrangler.IdGenerationConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.wrangler.IdGenerationConfig[IdGenerationConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.wrangler.IdGenerationConfig
                


              click network_wrangler.configs.wrangler.IdGenerationConfig href "" "network_wrangler.configs.wrangler.IdGenerationConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Model Roadway Configuration.

Attributes:

Name Type Description
TRANSIT_SHAPE_ID_METHOD Literal['scalar']

method for creating a shape_id for a transit shape. Should be “scalar”.

TRANSIT_SHAPE_ID_SCALAR int

scalar value to add to general purpose lane to create a shape_id for a transit shape.

ROAD_SHAPE_ID_METHOD Literal['scalar']

method for creating a shape_id for a roadway shape. Should be “scalar”.

ROAD_SHAPE_ID_SCALAR int

scalar value to add to general purpose lane to create a shape_id for a roadway shape.

ML_LINK_ID_METHOD Literal['range', 'scalar']

method for creating a model_link_id for an associated link for a parallel managed lane.

ML_LINK_ID_RANGE tuple[int, int]

range of model_link_ids to use when creating an associated link for a parallel managed lane.

ML_LINK_ID_SCALAR int

scalar value to add to general purpose lane to create a model_link_id when creating an associated link for a parallel managed lane.

ML_NODE_ID_METHOD Literal['range', 'scalar']

method for creating a model_node_id for an associated node for a parallel managed lane.

ML_NODE_ID_RANGE tuple[int, int]

range of model_node_ids to use when creating an associated node for a parallel managed lane.

ML_NODE_ID_SCALAR int

scalar value to add to general purpose lane node ides create a model_node_id when creating an associated nodes for parallel managed lane.

Source code in network_wrangler/configs/wrangler.py
@dataclass
class IdGenerationConfig(ConfigItem):
    """Model Roadway Configuration.

    Attributes:
        TRANSIT_SHAPE_ID_METHOD: method for creating a shape_id for a transit shape.
            Should be "scalar".
        TRANSIT_SHAPE_ID_SCALAR: scalar value to add to general purpose lane to create a
            shape_id for a transit shape.
        ROAD_SHAPE_ID_METHOD: method for creating a shape_id for a roadway shape.
            Should be "scalar".
        ROAD_SHAPE_ID_SCALAR: scalar value to add to general purpose lane to create a
            shape_id for a roadway shape.
        ML_LINK_ID_METHOD: method for creating a model_link_id for an associated
            link for a parallel managed lane.
        ML_LINK_ID_RANGE: range of model_link_ids to use when creating an associated
            link for a parallel managed lane.
        ML_LINK_ID_SCALAR: scalar value to add to general purpose lane to create a
            model_link_id when creating an associated link for a parallel managed lane.
        ML_NODE_ID_METHOD: method for creating a model_node_id for an associated node
            for a parallel managed lane.
        ML_NODE_ID_RANGE: range of model_node_ids to use when creating an associated
            node for a parallel managed lane.
        ML_NODE_ID_SCALAR: scalar value to add to general purpose lane node ides create
            a model_node_id when creating an associated nodes for parallel managed lane.
    """

    TRANSIT_SHAPE_ID_METHOD: Literal["scalar"] = "scalar"
    TRANSIT_SHAPE_ID_SCALAR: int = 1000000
    ROAD_SHAPE_ID_METHOD: Literal["scalar"] = "scalar"
    ROAD_SHAPE_ID_SCALAR: int = 1000
    ML_LINK_ID_METHOD: Literal["range", "scalar"] = "scalar"
    ML_LINK_ID_RANGE: tuple[int, int] = (950000, 999999)
    ML_LINK_ID_SCALAR: int = 3000000
    ML_NODE_ID_METHOD: Literal["range", "scalar"] = "range"
    ML_NODE_ID_RANGE: tuple[int, int] = (950000, 999999)
    ML_NODE_ID_SCALAR: int = 15000

network_wrangler.configs.wrangler.ModelRoadwayConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.wrangler.ModelRoadwayConfig[ModelRoadwayConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.wrangler.ModelRoadwayConfig
                


              click network_wrangler.configs.wrangler.ModelRoadwayConfig href "" "network_wrangler.configs.wrangler.ModelRoadwayConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Model Roadway Configuration.

Attributes:

Name Type Description
ML_OFFSET_METERS int

Offset in meters for managed lanes.

ADDITIONAL_COPY_FROM_GP_TO_ML list[str]

Additional fields to copy from general purpose to managed lanes.

ADDITIONAL_COPY_TO_ACCESS_EGRESS list[str]

Additional fields to copy to access and egress links.

Source code in network_wrangler/configs/wrangler.py
@dataclass
class ModelRoadwayConfig(ConfigItem):
    """Model Roadway Configuration.

    Attributes:
        ML_OFFSET_METERS: Offset in meters for managed lanes.
        ADDITIONAL_COPY_FROM_GP_TO_ML: Additional fields to copy from general purpose to managed
            lanes.
        ADDITIONAL_COPY_TO_ACCESS_EGRESS: Additional fields to copy to access and egress links.
    """

    ML_OFFSET_METERS: int = -10
    ADDITIONAL_COPY_FROM_GP_TO_ML: list[str] = Field(default_factory=list)
    ADDITIONAL_COPY_TO_ACCESS_EGRESS: list[str] = Field(default_factory=list)

network_wrangler.configs.wrangler.TransitConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.wrangler.TransitConfig[TransitConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.wrangler.TransitConfig
                


              click network_wrangler.configs.wrangler.TransitConfig href "" "network_wrangler.configs.wrangler.TransitConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Transit Configuration.

Attributes:

Name Type Description
K_NEAREST_CANDIDATES int

Number of nearest candidate nodes to consider in stop matching when using name scoring. Used in match_bus_stops_to_roadway_nodes().

NAME_MATCH_WEIGHT float

Weight for name match score in combined scoring. 0.9 means 90% name match, 10% distance. Used in match_bus_stops_to_roadway_nodes().

MIN_SUBSTRING_MATCH_LENGTH int

Minimum string length required for substring matching. Prevents spurious matches with single letters. Used in assess_stop_name_roadway_compatibility().

SHAPE_DISTANCE_TOLERANCE float

Maximum ratio of path distance to shortest distance in shape-aware routing. 1.10 means paths up to 110% of shortest distance are considered. Used in route_shapes_between_stops() and find_shape_aware_shortest_path().

MAX_SHAPE_CANDIDATE_PATHS int

Maximum number of candidate paths to evaluate when doing shape-aware routing. Used in find_shape_aware_shortest_path().

NEAREST_K_SHAPES_TO_STOPS int

Number of nearest shape points to check for each stop.

FIRST_LAST_SHAPE_STOP_IDX int

For loops, the first stop must match one of the first FIRST_LAST_SHAPE_STOP_IDX shapes, and the last stop must match one of the last FIRST_LAST_SHAPE_STOP_IDX shapes. Used in route_shapes_between_stops().

MAX_DISTANCE_STOP_FEET float

Maximum distance in feet for a stop to match to a node. Used in match_bus_stops_to_roadway_nodes().

MAX_DISTANCE_STOP_METERS float

Maximum distance in meters for a stop to match to a node. Used in match_bus_stops_to_roadway_nodes().

Source code in network_wrangler/configs/wrangler.py
@dataclass
class TransitConfig(ConfigItem):
    """Transit Configuration.

    Attributes:
        K_NEAREST_CANDIDATES: Number of nearest candidate nodes to consider in stop matching
            when using name scoring. Used in
            [`match_bus_stops_to_roadway_nodes()`][network_wrangler.utils.transit.match_bus_stops_to_roadway_nodes].
        NAME_MATCH_WEIGHT: Weight for name match score in combined scoring. 0.9 means 90% name
            match, 10% distance. Used in
            [`match_bus_stops_to_roadway_nodes()`][network_wrangler.utils.transit.match_bus_stops_to_roadway_nodes].
        MIN_SUBSTRING_MATCH_LENGTH: Minimum string length required for substring matching.
            Prevents spurious matches with single letters. Used in
            [`assess_stop_name_roadway_compatibility()`][network_wrangler.utils.transit.assess_stop_name_roadway_compatibility].
        SHAPE_DISTANCE_TOLERANCE: Maximum ratio of path distance to shortest distance in
            shape-aware routing. 1.10 means paths up to 110% of shortest distance are considered.
            Used in [`route_shapes_between_stops()`][network_wrangler.utils.transit.route_shapes_between_stops]
            and [`find_shape_aware_shortest_path()`][network_wrangler.utils.transit.find_shape_aware_shortest_path].
        MAX_SHAPE_CANDIDATE_PATHS: Maximum number of candidate paths to evaluate when doing
            shape-aware routing. Used in
            [`find_shape_aware_shortest_path()`][network_wrangler.utils.transit.find_shape_aware_shortest_path].
        NEAREST_K_SHAPES_TO_STOPS: Number of nearest shape points to check for each stop.
        FIRST_LAST_SHAPE_STOP_IDX: For loops, the first stop must match one of the first
            FIRST_LAST_SHAPE_STOP_IDX shapes, and the last stop must match one of the last
            FIRST_LAST_SHAPE_STOP_IDX shapes. Used in
            [`route_shapes_between_stops()`][network_wrangler.utils.transit.route_shapes_between_stops].
        MAX_DISTANCE_STOP_FEET: Maximum distance in feet for a stop to match to a node.
            Used in [`match_bus_stops_to_roadway_nodes()`][network_wrangler.utils.transit.match_bus_stops_to_roadway_nodes].
        MAX_DISTANCE_STOP_METERS: Maximum distance in meters for a stop to match to a node.
            Used in [`match_bus_stops_to_roadway_nodes()`][network_wrangler.utils.transit.match_bus_stops_to_roadway_nodes].
    """

    K_NEAREST_CANDIDATES: int = 20
    NAME_MATCH_WEIGHT: float = 0.9
    MIN_SUBSTRING_MATCH_LENGTH: int = 3
    SHAPE_DISTANCE_TOLERANCE: float = 1.10
    MAX_SHAPE_CANDIDATE_PATHS: int = 20
    NEAREST_K_SHAPES_TO_STOPS: int = 20
    FIRST_LAST_SHAPE_STOP_IDX: int = 10
    MAX_DISTANCE_STOP_FEET: float = 528.0  # 0.10 * 5280 FEET_PER_MILE
    MAX_DISTANCE_STOP_METERS: float = 150.0  # 0.15 * 1000 METERS_PER_KILOMETER

network_wrangler.configs.wrangler.WranglerConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.wrangler.WranglerConfig[WranglerConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.wrangler.WranglerConfig
                


              click network_wrangler.configs.wrangler.WranglerConfig href "" "network_wrangler.configs.wrangler.WranglerConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Configuration for Network Wrangler.

Attributes:

Name Type Description
IDS IdGenerationConfig

Parameteters governing how new ids are generated.

MODEL_ROADWAY ModelRoadwayConfig

Parameters governing how the model roadway is created.

TRANSIT TransitConfig

Parameters governing transit network processing.

CPU CpuConfig

Parameters for accessing CPU information. Will not change any outcomes.

EDITS EditsConfig

Parameters governing how edits are handled.

Source code in network_wrangler/configs/wrangler.py
@dataclass
class WranglerConfig(ConfigItem):
    """Configuration for Network Wrangler.

    Attributes:
        IDS: Parameteters governing how new ids are generated.
        MODEL_ROADWAY: Parameters governing how the model roadway is created.
        TRANSIT: Parameters governing transit network processing.
        CPU: Parameters for accessing CPU information. Will not change any outcomes.
        EDITS: Parameters governing how edits are handled.
    """

    IDS: IdGenerationConfig = Field(default_factory=IdGenerationConfig)
    MODEL_ROADWAY: ModelRoadwayConfig = Field(default_factory=ModelRoadwayConfig)
    TRANSIT: TransitConfig = Field(default_factory=TransitConfig)
    CPU: CpuConfig = Field(default_factory=CpuConfig)
    EDITS: EditsConfig = Field(default_factory=EditsConfig)

Scenario configuration for Network Wrangler.

You can build a scenario and write out the output from a scenario configuration file using the code below. This is very useful when you are running a specific scenario with minor variations over again because you can enter your config file into version control. In addition to the completed roadway and transit files, the output will provide a record of how the scenario was run.

Usage
    from scenario import build_scenario_from_config
    my_scenario = build_scenario_from_config(my_scenario_config)

Where my_scenario_config can be a:

  • Path to a scenario config file in yaml/toml/json (recommended),
  • Dictionary which is in the same structure of a scenario config file, or
  • A ScenarioConfig() instance.

Notes on relative paths in scenario configs

  • Relative paths are recognized by a preceeding “.”.
  • Relative paths within output_scenario for roadway, transit, and project_cards are interpreted to be relative to output_scenario.path.
  • All other relative paths are interpreted to be relative to directory of the scenario config file. (Or if scenario config is provided as a dictionary, relative paths will be interpreted as relative to the current working directory.)
Example Scenario Config
name: "my_scenario"
base_scenario:
    roadway:
        dir: "path/to/roadway_network"
        file_format: "geojson"
        read_in_shapes: True
    transit:
        dir: "path/to/transit_network"
        file_format: "txt"
    applied_projects:
        - "project1"
        - "project2"
    conflicts:
        "project3": ["project1", "project2"]
        "project4": ["project1"]
projects:
    project_card_filepath:
        - "path/to/projectA.yaml"
        - "path/to/projectB.yaml"
    filter_tags:
        - "tag1"
output_scenario:
    overwrite: True
    roadway:
        out_dir: "path/to/output/roadway"
        prefix: "my_scenario"
        file_format: "geojson"
        true_shape: False
    transit:
        out_dir: "path/to/output/transit"
        prefix: "my_scenario"
        file_format: "txt"
    project_cards:
        out_dir: "path/to/output/project_cards"

wrangler_config: "path/to/wrangler_config.yaml"
Extended Usage

Load a configuration from a file:

from network_wrangler.configs import load_scenario_config

my_scenario_config = load_scenario_config("path/to/config.yaml")

Access the configuration:

my_scenario_config.base_transit_network.path
>> path/to/transit_network

network_wrangler.configs.scenario.ProjectCardOutputConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.scenario.ProjectCardOutputConfig[ProjectCardOutputConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.scenario.ProjectCardOutputConfig
                


              click network_wrangler.configs.scenario.ProjectCardOutputConfig href "" "network_wrangler.configs.scenario.ProjectCardOutputConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Configuration for outputing project cards in a scenario.

Attributes:

Name Type Description
out_dir

Path to write the project card files to if you don’t want to use the default.

write

If True, will write the project cards. Defaults to True.

Source code in network_wrangler/configs/scenario.py
class ProjectCardOutputConfig(ConfigItem):
    """Configuration for outputing project cards in a scenario.

    Attributes:
        out_dir: Path to write the project card files to if you don't want to use the default.
        write: If True, will write the project cards. Defaults to True.
    """

    def __init__(
        self,
        base_path: Path = DEFAULT_BASE_DIR,
        out_dir: Path = DEFAULT_PROJECT_OUT_DIR,
        write: bool = DEFAULT_PROJECT_WRITE,
    ):
        """Constructor for ProjectCardOutputConfig."""
        if out_dir is not None and not Path(out_dir).is_absolute():
            self.out_dir = (base_path / Path(out_dir)).resolve()
        else:
            self.out_dir = Path(out_dir)
        self.write = write

network_wrangler.configs.scenario.ProjectsConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.scenario.ProjectsConfig[ProjectsConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.scenario.ProjectsConfig
                


              click network_wrangler.configs.scenario.ProjectsConfig href "" "network_wrangler.configs.scenario.ProjectsConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Configuration for projects in a scenario.

Attributes:

Name Type Description
project_card_filepath

where the project card is. A single path, list of paths, a directory, or a glob pattern. Defaults to None.

filter_tags

List of tags to filter the project cards by.

Source code in network_wrangler/configs/scenario.py
class ProjectsConfig(ConfigItem):
    """Configuration for projects in a scenario.

    Attributes:
        project_card_filepath: where the project card is.  A single path, list of paths,
            a directory, or a glob pattern. Defaults to None.
        filter_tags: List of tags to filter the project cards by.
    """

    def __init__(
        self,
        base_path: Path = DEFAULT_BASE_DIR,
        project_card_filepath: ProjectCardFilepaths = DEFAULT_PROJECT_IN_PATHS,
        filter_tags: list[str] = DEFAULT_PROJECT_TAGS,
    ):
        """Constructor for ProjectsConfig."""
        self.project_card_filepath = _resolve_rel_paths(project_card_filepath, base_path=base_path)
        self.filter_tags = filter_tags

network_wrangler.configs.scenario.RoadwayNetworkInputConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.scenario.RoadwayNetworkInputConfig[RoadwayNetworkInputConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.scenario.RoadwayNetworkInputConfig
                


              click network_wrangler.configs.scenario.RoadwayNetworkInputConfig href "" "network_wrangler.configs.scenario.RoadwayNetworkInputConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Configuration for the road network in a scenario.

Attributes:

Name Type Description
dir

Path to directory with roadway network files.

file_format

File format for the roadway network files. Should be one of RoadwayFileTypes. Defaults to “geojson”.

read_in_shapes

If True, will read in the shapes of the roadway network. Defaults to False.

boundary_geocode

Geocode of the boundary. Will use this to filter the roadway network.

boundary_file

Path to the boundary file. If provided and both boundary_gdf and boundary_geocode are not provided, will use this to filter the roadway network.

Source code in network_wrangler/configs/scenario.py
class RoadwayNetworkInputConfig(ConfigItem):
    """Configuration for the road network in a scenario.

    Attributes:
        dir: Path to directory with roadway network files.
        file_format: File format for the roadway network files. Should be one of RoadwayFileTypes.
            Defaults to "geojson".
        read_in_shapes: If True, will read in the shapes of the roadway network. Defaults to False.
        boundary_geocode: Geocode of the boundary. Will use this to filter the roadway network.
        boundary_file: Path to the boundary file. If provided and both boundary_gdf and
            boundary_geocode are not provided, will use this to filter the roadway network.
    """

    def __init__(
        self,
        base_path: Path = DEFAULT_BASE_DIR,
        dir: Path = DEFAULT_ROADWAY_IN_DIR,
        file_format: RoadwayFileTypes = DEFAULT_ROADWAY_IN_FORMAT,
        read_in_shapes: bool = DEFAULT_ROADWAY_SHAPE_READ,
        boundary_geocode: str | None = None,
        boundary_file: Path | None = None,
    ):
        """Constructor for RoadwayNetworkInputConfig."""
        if dir is not None and not Path(dir).is_absolute():
            self.dir = (base_path / Path(dir)).resolve()
        else:
            self.dir = Path(dir)
        self.file_format = file_format
        self.read_in_shapes = read_in_shapes
        self.boundary_geocode = boundary_geocode
        self.boundary_file = boundary_file

network_wrangler.configs.scenario.RoadwayNetworkOutputConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.scenario.RoadwayNetworkOutputConfig[RoadwayNetworkOutputConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.scenario.RoadwayNetworkOutputConfig
                


              click network_wrangler.configs.scenario.RoadwayNetworkOutputConfig href "" "network_wrangler.configs.scenario.RoadwayNetworkOutputConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Configuration for writing out the resulting roadway network for a scenario.

Attributes:

Name Type Description
out_dir

Path to write the roadway network files to if you don’t want to use the default.

prefix

Prefix to add to the file name. If not provided will use the scenario name.

file_format

File format to write the roadway network to. Should be one of RoadwayFileTypes. Defaults to “geojson”.

true_shape

If True, will write the true shape of the roadway network. Defaults to False.

write

If True, will write the roadway network. Defaults to True.

Source code in network_wrangler/configs/scenario.py
class RoadwayNetworkOutputConfig(ConfigItem):
    """Configuration for writing out the resulting roadway network for a scenario.

    Attributes:
        out_dir: Path to write the roadway network files to if you don't want to use the default.
        prefix: Prefix to add to the file name. If not provided will use the scenario name.
        file_format: File format to write the roadway network to. Should be one of
            RoadwayFileTypes. Defaults to "geojson".
        true_shape: If True, will write the true shape of the roadway network. Defaults to False.
        write: If True, will write the roadway network. Defaults to True.
    """

    def __init__(
        self,
        out_dir: Path = DEFAULT_ROADWAY_OUT_DIR,
        base_path: Path = DEFAULT_BASE_DIR,
        convert_complex_link_properties_to_single_field: bool = False,
        prefix: str | None = None,
        file_format: RoadwayFileTypes = DEFAULT_ROADWAY_OUT_FORMAT,
        true_shape: bool = False,
        write: bool = DEFAULT_ROADWAY_WRITE,
    ):
        """Constructor for RoadwayNetworkOutputConfig."""
        if out_dir is not None and not Path(out_dir).is_absolute():
            self.out_dir = (base_path / Path(out_dir)).resolve()
        else:
            self.out_dir = Path(out_dir)

        self.convert_complex_link_properties_to_single_field = (
            convert_complex_link_properties_to_single_field
        )
        self.prefix = prefix
        self.file_format = file_format
        self.true_shape = true_shape
        self.write = write

network_wrangler.configs.scenario.ScenarioConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.scenario.ScenarioConfig[ScenarioConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.scenario.ScenarioConfig
                


              click network_wrangler.configs.scenario.ScenarioConfig href "" "network_wrangler.configs.scenario.ScenarioConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Scenario configuration for Network Wrangler.

Attributes:

Name Type Description
base_path

base path of the scenario. Defaults to cwd.

name

Name of the scenario.

base_scenario

information about the base scenario

projects

information about the projects to apply on top of the base scenario

output_scenario

information about how to output the scenario

wrangler_config

wrangler configuration to use

Source code in network_wrangler/configs/scenario.py
class ScenarioConfig(ConfigItem):
    """Scenario configuration for Network Wrangler.

    Attributes:
        base_path: base path of the scenario. Defaults to cwd.
        name: Name of the scenario.
        base_scenario: information about the base scenario
        projects: information about the projects to apply on top of the base scenario
        output_scenario: information about how to output the scenario
        wrangler_config: wrangler configuration to use
    """

    def __init__(
        self,
        base_scenario: dict,
        projects: dict,
        output_scenario: dict,
        base_path: Path = DEFAULT_BASE_DIR,
        name: str = DEFAULT_SCENARIO_NAME,
        wrangler_config=DefaultConfig,
    ):
        """Constructor for ScenarioConfig."""
        self.base_path = Path(base_path) if base_path is not None else Path.cwd()
        self.name = name
        self.base_scenario = ScenarioInputConfig(**base_scenario, base_path=base_path)
        self.projects = ProjectsConfig(**projects, base_path=base_path)
        self.output_scenario = ScenarioOutputConfig(**output_scenario, base_path=base_path)
        self.wrangler_config = wrangler_config

network_wrangler.configs.scenario.ScenarioInputConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.scenario.ScenarioInputConfig[ScenarioInputConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.scenario.ScenarioInputConfig
                


              click network_wrangler.configs.scenario.ScenarioInputConfig href "" "network_wrangler.configs.scenario.ScenarioInputConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Configuration for the writing the output of a scenario.

Attributes:

Name Type Description
roadway RoadwayNetworkInputConfig | None

Configuration for writing out the roadway network.

transit TransitNetworkInputConfig | None

Configuration for writing out the transit network.

applied_projects

List of projects to apply to the base scenario.

conflicts

Dict of projects that conflict with the applied_projects.

Source code in network_wrangler/configs/scenario.py
class ScenarioInputConfig(ConfigItem):
    """Configuration for the writing the output of a scenario.

    Attributes:
        roadway: Configuration for writing out the roadway network.
        transit: Configuration for writing out the transit network.
        applied_projects: List of projects to apply to the base scenario.
        conflicts: Dict of projects that conflict with the applied_projects.
    """

    def __init__(
        self,
        base_path: Path = DEFAULT_BASE_DIR,
        roadway: dict | None = None,
        transit: dict | None = None,
        applied_projects: list[str] | None = None,
        conflicts: dict | None = None,
    ):
        """Constructor for ScenarioInputConfig."""
        if roadway is not None:
            self.roadway: RoadwayNetworkInputConfig | None = RoadwayNetworkInputConfig(
                **roadway, base_path=base_path
            )
        else:
            self.roadway = None

        if transit is not None:
            self.transit: TransitNetworkInputConfig | None = TransitNetworkInputConfig(
                **transit, base_path=base_path
            )
        else:
            self.transit = None

        self.applied_projects = applied_projects if applied_projects is not None else []
        self.conflicts = conflicts if conflicts is not None else {}

network_wrangler.configs.scenario.ScenarioOutputConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.scenario.ScenarioOutputConfig[ScenarioOutputConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.scenario.ScenarioOutputConfig
                


              click network_wrangler.configs.scenario.ScenarioOutputConfig href "" "network_wrangler.configs.scenario.ScenarioOutputConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Configuration for the writing the output of a scenario.

Attributes:

Name Type Description
roadway

Configuration for writing out the roadway network.

transit

Configuration for writing out the transit network.

project_cards ProjectCardOutputConfig | None

Configuration for writing out the project cards.

overwrite

If True, will overwrite the files if they already exist. Defaults to True

Source code in network_wrangler/configs/scenario.py
class ScenarioOutputConfig(ConfigItem):
    """Configuration for the writing the output of a scenario.

    Attributes:
        roadway: Configuration for writing out the roadway network.
        transit: Configuration for writing out the transit network.
        project_cards: Configuration for writing out the project cards.
        overwrite: If True, will overwrite the files if they already exist. Defaults to True
    """

    def __init__(
        self,
        path: Path = DEFAULT_OUTPUT_DIR,
        base_path: Path = DEFAULT_BASE_DIR,
        roadway: dict | None = None,
        transit: dict | None = None,
        project_cards: dict | None = None,
        overwrite: bool = True,
    ):
        """Constructor for ScenarioOutputConfig."""
        if not Path(path).is_absolute():
            self.path = (base_path / Path(path)).resolve()
        else:
            self.path = Path(path)

        roadway = roadway if roadway else RoadwayNetworkOutputConfig().to_dict()
        transit = transit if transit else TransitNetworkOutputConfig().to_dict()
        self.roadway = RoadwayNetworkOutputConfig(**roadway, base_path=self.path)
        self.transit = TransitNetworkOutputConfig(**transit, base_path=self.path)

        if project_cards is not None:
            self.project_cards: ProjectCardOutputConfig | None = ProjectCardOutputConfig(
                **project_cards, base_path=self.path
            )
        else:
            self.project_cards = None

        self.overwrite = overwrite

network_wrangler.configs.scenario.TransitNetworkInputConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.scenario.TransitNetworkInputConfig[TransitNetworkInputConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.scenario.TransitNetworkInputConfig
                


              click network_wrangler.configs.scenario.TransitNetworkInputConfig href "" "network_wrangler.configs.scenario.TransitNetworkInputConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Configuration for the transit network in a scenario.

Attributes:

Name Type Description
dir

Path to the transit network files. Defaults to “.”.

file_format

File format for the transit network files. Should be one of TransitFileTypes. Defaults to “txt”.

Source code in network_wrangler/configs/scenario.py
class TransitNetworkInputConfig(ConfigItem):
    """Configuration for the transit network in a scenario.

    Attributes:
        dir: Path to the transit network files. Defaults to ".".
        file_format: File format for the transit network files. Should be one of TransitFileTypes.
            Defaults to "txt".
    """

    def __init__(
        self,
        base_path: Path = DEFAULT_BASE_DIR,
        dir: Path = DEFAULT_TRANSIT_IN_DIR,
        file_format: TransitFileTypes = DEFAULT_TRANSIT_IN_FORMAT,
    ):
        """Constructor for TransitNetworkInputConfig."""
        if dir is not None and not Path(dir).is_absolute():
            self.feed = (base_path / Path(dir)).resolve()
        else:
            self.feed = Path(dir)
        self.file_format = file_format

network_wrangler.configs.scenario.TransitNetworkOutputConfig

Bases: ConfigItem


              flowchart TD
              network_wrangler.configs.scenario.TransitNetworkOutputConfig[TransitNetworkOutputConfig]
              network_wrangler.configs.utils.ConfigItem[ConfigItem]

                              network_wrangler.configs.utils.ConfigItem --> network_wrangler.configs.scenario.TransitNetworkOutputConfig
                


              click network_wrangler.configs.scenario.TransitNetworkOutputConfig href "" "network_wrangler.configs.scenario.TransitNetworkOutputConfig"
              click network_wrangler.configs.utils.ConfigItem href "" "network_wrangler.configs.utils.ConfigItem"
            

Configuration for the transit network in a scenario.

Attributes:

Name Type Description
out_dir

Path to write the transit network files to if you don’t want to use the default.

prefix

Prefix to add to the file name. If not provided will use the scenario name.

file_format

File format to write the transit network to. Should be one of TransitFileTypes. Defaults to “txt”.

write

If True, will write the transit network. Defaults to True.

Source code in network_wrangler/configs/scenario.py
class TransitNetworkOutputConfig(ConfigItem):
    """Configuration for the transit network in a scenario.

    Attributes:
        out_dir: Path to write the transit network files to if you don't want to use the default.
        prefix: Prefix to add to the file name. If not provided will use the scenario name.
        file_format: File format to write the transit network to. Should be one of
            TransitFileTypes. Defaults to "txt".
        write: If True, will write the transit network. Defaults to True.
    """

    def __init__(
        self,
        base_path: Path = DEFAULT_BASE_DIR,
        out_dir: Path = DEFAULT_TRANSIT_OUT_DIR,
        prefix: str | None = None,
        file_format: TransitFileTypes = DEFAULT_TRANSIT_OUT_FORMAT,
        write: bool = DEFAULT_TRANSIT_WRITE,
    ):
        """Constructor for TransitNetworkOutputCOnfig."""
        if out_dir is not None and not Path(out_dir).is_absolute():
            self.out_dir = (base_path / Path(out_dir)).resolve()
        else:
            self.out_dir = Path(out_dir)
        self.write = write
        self.prefix = prefix
        self.file_format = file_format

Configuration utilities.

network_wrangler.configs.utils.ConfigItem

Base class to add partial dict-like interface to configuration.

Allow use of .items() [“X”] and .get(“X”) .to_dict() from configuration.

Not to be constructed directly. To be used a mixin for dataclasses representing config schema. Do not use “get” “to_dict”, or “items” for key names.

Source code in network_wrangler/configs/utils.py
class ConfigItem:
    """Base class to add partial dict-like interface to  configuration.

    Allow use of .items() ["X"] and .get("X") .to_dict() from configuration.

    Not to be constructed directly. To be used a mixin for dataclasses
    representing config schema.
    Do not use "get" "to_dict", or "items" for key names.
    """

    base_path: Path | None = None

    def __getitem__(self, key):
        """Return the value for key if key is in the dictionary, else default."""
        return getattr(self, key)

    def items(self):
        """A set-like object providing a view on D's items."""
        return self.__dict__.items()

    def to_dict(self):
        """Convert the configuration to a dictionary."""
        result = {}
        for key, value in self.__dict__.items():
            if isinstance(value, ConfigItem):
                result[key] = value.to_dict()
            else:
                result[key] = value
        return result

    def get(self, key, default=None):
        """Return the value for key if key is in the dictionary, else default."""
        return self.__dict__.get(key, default)

    def update(self, data: Path | list[Path] | dict):
        """Update the configuration with a dictionary of new values."""
        if not isinstance(data, dict):
            WranglerLogger.info(f"Updating configuration with {data}.")
            data = load_merge_dict(data)

        self.__dict__.update(data)
        return self

    def resolve_paths(self, base_path):
        """Resolve relative paths in the configuration."""
        base_path = Path(base_path)
        for key, value in self.__dict__.items():
            if isinstance(value, ConfigItem):
                value.resolve_paths(base_path)
            elif isinstance(value, str) and value.startswith("."):
                resolved_path = (base_path / value).resolve()
                setattr(self, key, str(resolved_path))

network_wrangler.configs.utils.ConfigItem.__getitem__

__getitem__(key)

Return the value for key if key is in the dictionary, else default.

Source code in network_wrangler/configs/utils.py
def __getitem__(self, key):
    """Return the value for key if key is in the dictionary, else default."""
    return getattr(self, key)

network_wrangler.configs.utils.ConfigItem.get

get(key, default=None)

Return the value for key if key is in the dictionary, else default.

Source code in network_wrangler/configs/utils.py
def get(self, key, default=None):
    """Return the value for key if key is in the dictionary, else default."""
    return self.__dict__.get(key, default)

network_wrangler.configs.utils.ConfigItem.items

items()

A set-like object providing a view on D’s items.

Source code in network_wrangler/configs/utils.py
def items(self):
    """A set-like object providing a view on D's items."""
    return self.__dict__.items()

network_wrangler.configs.utils.ConfigItem.resolve_paths

resolve_paths(base_path)

Resolve relative paths in the configuration.

Source code in network_wrangler/configs/utils.py
def resolve_paths(self, base_path):
    """Resolve relative paths in the configuration."""
    base_path = Path(base_path)
    for key, value in self.__dict__.items():
        if isinstance(value, ConfigItem):
            value.resolve_paths(base_path)
        elif isinstance(value, str) and value.startswith("."):
            resolved_path = (base_path / value).resolve()
            setattr(self, key, str(resolved_path))

network_wrangler.configs.utils.ConfigItem.to_dict

to_dict()

Convert the configuration to a dictionary.

Source code in network_wrangler/configs/utils.py
def to_dict(self):
    """Convert the configuration to a dictionary."""
    result = {}
    for key, value in self.__dict__.items():
        if isinstance(value, ConfigItem):
            result[key] = value.to_dict()
        else:
            result[key] = value
    return result

network_wrangler.configs.utils.ConfigItem.update

update(data)

Update the configuration with a dictionary of new values.

Source code in network_wrangler/configs/utils.py
def update(self, data: Path | list[Path] | dict):
    """Update the configuration with a dictionary of new values."""
    if not isinstance(data, dict):
        WranglerLogger.info(f"Updating configuration with {data}.")
        data = load_merge_dict(data)

    self.__dict__.update(data)
    return self

network_wrangler.configs.utils.find_configs_in_dir

find_configs_in_dir(dir, config_type)

Find configuration files in the directory that match *config<ext>.

Source code in network_wrangler/configs/utils.py
def find_configs_in_dir(dir: Path | list[Path], config_type) -> list[Path]:
    """Find configuration files in the directory that match `*config<ext>`."""
    config_files: list[Path] = []
    if isinstance(dir, list):
        for d in dir:
            config_files.extend(find_configs_in_dir(d, config_type))
    elif dir.is_dir():
        dir = Path(dir)
        for ext in SUPPORTED_CONFIG_EXTENSIONS:
            config_like_files = list(dir.glob(f"*config{ext}"))
            config_files.extend(find_configs_in_dir(config_like_files, config_type))
    elif dir.is_file():
        try:
            config_type(load_dict(dir))
        except ValidationError:
            return config_files
        config_files.append(dir)

    if config_files:
        return [Path(config_file) for config_file in config_files]
    return []