1+ import re
2+ import unicodedata
13from abc import ABC , abstractmethod
24from collections import defaultdict
35from dataclasses import dataclass
46from datetime import datetime , timedelta , timezone
7+ from pathlib import Path
58from typing import Callable , Iterable , Optional
69
10+ from PIL .Image import Image
11+
712from OTAnalytics .application .analysis .traffic_counting_specification import (
813 CountingSpecificationDto ,
914 ExportCounts ,
1924from OTAnalytics .domain .section import Section , SectionId
2025from OTAnalytics .domain .types import EventType
2126
27+ DATETIME_FORMAT = "%Y-%m-%d_%H-%M-%S"
28+
2229LEVEL_FROM_SECTION = "from section"
2330LEVEL_TO_SECTION = "to section"
2431LEVEL_FLOW = "flow"
@@ -995,9 +1002,9 @@ def create_export_specification(
9951002 return ExportSpecificationDto (counting_specification , flow_dtos )
9961003
9971004
998- class ExportTrafficCounting ( ExportCounts ) :
1005+ class TrafficCounting :
9991006 """
1000- Use case to export traffic counting .
1007+ Use case to produce traffic counts .
10011008 """
10021009
10031010 def __init__ (
@@ -1008,36 +1015,82 @@ def __init__(
10081015 create_events : CreateEvents ,
10091016 assigner : RoadUserAssigner ,
10101017 tagger_factory : TaggerFactory ,
1011- exporter_factory : ExporterFactory ,
1012- ) -> None :
1018+ ):
10131019 self ._event_repository = event_repository
10141020 self ._flow_repository = flow_repository
10151021 self ._get_sections_by_id = get_sections_by_id
10161022 self ._create_events = create_events
10171023 self ._assigner = assigner
10181024 self ._tagger_factory = tagger_factory
1019- self ._exporter_factory = exporter_factory
10201025
1021- def export (self , specification : CountingSpecificationDto ) -> None :
1026+ def count (self , specification : CountingSpecificationDto ) -> Count :
10221027 """
1023- Export the traffic countings based on the currently available events and flows.
1028+ Produce traffic counts based on the currently available events and flows.
10241029
10251030 Args:
10261031 specification (CountingSpecificationDto): specification of the export
10271032 """
10281033 if self ._event_repository .is_empty ():
10291034 self ._create_events ()
1030- events = self ._event_repository .get (
1031- start_date = specification .start ,
1032- end_date = specification .end ,
1033- )
1034- flows = self ._flow_repository .get_all ()
1035+
1036+ if specification .count_all_events :
1037+ events = self ._event_repository .get_all ()
1038+ else :
1039+ events = self ._event_repository .get (
1040+ start_date = specification .start ,
1041+ end_date = specification .end ,
1042+ )
1043+
1044+ flows = self .get_flows ()
10351045 assigned_flows = self ._assigner .assign (events , flows )
10361046 tagger = self ._tagger_factory .create_tagger (specification )
10371047 tagged_assignments = assigned_flows .tag (tagger )
1038- counts = tagged_assignments .count (flows )
1048+ return tagged_assignments .count (flows )
1049+
1050+ def get_flows (self ) -> list [Flow ]:
1051+ return self ._flow_repository .get_all ()
1052+
1053+ def with_tagger_factory (
1054+ self , new_tagger_factory : TaggerFactory
1055+ ) -> "TrafficCounting" :
1056+ return TrafficCounting (
1057+ self ._event_repository ,
1058+ self ._flow_repository ,
1059+ self ._get_sections_by_id ,
1060+ self ._create_events ,
1061+ self ._assigner ,
1062+ new_tagger_factory ,
1063+ )
1064+
1065+ def provide_get_sections_by_id (self ) -> GetSectionsById :
1066+ return self ._get_sections_by_id
1067+
1068+
1069+ class ExportTrafficCounting (ExportCounts ):
1070+ """
1071+ Use case to export traffic counting.
1072+ """
1073+
1074+ def __init__ (
1075+ self ,
1076+ traffic_counting : TrafficCounting ,
1077+ exporter_factory : ExporterFactory ,
1078+ ) -> None :
1079+ self ._traffic_counting = traffic_counting
1080+ self ._exporter_factory = exporter_factory
1081+
1082+ def export (self , specification : CountingSpecificationDto ) -> None :
1083+ """
1084+ Export the traffic countings based on the currently available events and flows.
1085+
1086+ Args:
1087+ specification (CountingSpecificationDto): specification of the export
1088+ """
1089+ counts = self ._traffic_counting .count (specification )
1090+
1091+ flows = self ._traffic_counting .get_flows ()
10391092 export_specification = create_export_specification (
1040- flows , specification , self ._get_sections_by_id
1093+ flows , specification , self ._traffic_counting . provide_get_sections_by_id ()
10411094 )
10421095 exporter = self ._exporter_factory .create_exporter (export_specification )
10431096 exporter .export (counts , specification .export_mode )
@@ -1050,3 +1103,49 @@ def get_supported_formats(self) -> Iterable[ExportFormat]:
10501103 Iterable[ExportFormat]: supported export formats
10511104 """
10521105 return self ._exporter_factory .get_supported_formats ()
1106+
1107+
1108+ @dataclass (frozen = True )
1109+ class CountImage :
1110+ """
1111+ Represents an image with a counts plot.
1112+ """
1113+
1114+ image : Image
1115+ width : int
1116+ height : int
1117+ name : str
1118+ timestamp : datetime
1119+
1120+ def save (self , save_dir : Path ) -> None :
1121+ time = self .timestamp .strftime (DATETIME_FORMAT )
1122+
1123+ save_dir .mkdir (parents = True , exist_ok = True )
1124+
1125+ self .image .save (save_dir / f"counts_plot_{ self .safe_filename ()} _{ time } .png" )
1126+
1127+ def safe_filename (self , max_length : int = 255 , replacement : str = "_" ) -> str :
1128+ """Create a string from the image name that can be used as the filename.
1129+
1130+ Use ascii only and replace illegal characters (/:*?"<>|),
1131+ whitespaces and linebreaks.
1132+ Remove sequences of replacement char and truncate to max_length.
1133+
1134+ Args:
1135+ max_length (int, optional): length for truncating the resulting file name.
1136+ Defaults to 255.
1137+ replacement (str, optional): character to replace illegal characters
1138+ in image name. Defaults to "_".
1139+
1140+ Returns:
1141+ str: string safe to use as filename
1142+ """
1143+ safe = (
1144+ unicodedata .normalize ("NFKD" , self .name ).encode ("ascii" , "ignore" ).decode ()
1145+ )
1146+ safe = re .sub (r'[<>:"/\\|?*\n\r\t]' , replacement , safe )
1147+ safe = re .sub (r"\s+" , replacement , safe )
1148+ safe = re .sub (rf"{ re .escape (replacement )} +" , replacement , safe )
1149+ safe = safe .strip (replacement )
1150+ # Truncate to max length (preserving file extension if needed)
1151+ return safe [:max_length ]
0 commit comments