bloomr 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bloomr/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """
2
+ Bloomr: Memory-efficient Chinese Postman Problem solver.
3
+
4
+ Bloomr is a modern Python package for solving the Chinese Postman Problem (CPP)
5
+ on road networks. It uses a high-performance Rust backend with the blossom algorithm
6
+ for efficient perfect matching.
7
+
8
+ The package provides a simple, pythonic API for:
9
+ - Downloading road networks from OpenStreetMap via OSMnx
10
+ - Computing optimal routes that traverse every road segment at least once
11
+ - Generating GPS-compatible GPX files for navigation
12
+
13
+ Basic Usage:
14
+ >>> from bloomr import solve_cpp
15
+ >>>
16
+ >>> # Solve CPP for a region
17
+ >>> result = solve_cpp("Jersey, Channel Islands")
18
+ >>> print(result.summary())
19
+ >>>
20
+ >>> # Access the GPX route file
21
+ >>> print(f"GPX route: {result.gpx_path}")
22
+
23
+ Advanced Usage:
24
+ >>> from bloomr import download_graph, solve_cpp
25
+ >>>
26
+ >>> # Download and cache a graph
27
+ >>> graph_path = download_graph("San Francisco, California")
28
+ >>>
29
+ >>> # Solve using the cached graph
30
+ >>> result = solve_cpp(graphml_path=graph_path)
31
+ """
32
+
33
+ from .download import download_graph, sanitize_filename
34
+ from .solver import CPPResult, solve_cpp
35
+
36
+ __version__ = "0.1.1"
37
+
38
+ __all__ = [
39
+ "solve_cpp",
40
+ "download_graph",
41
+ "sanitize_filename",
42
+ "CPPResult",
43
+ ]
44
+
45
+ # Visualization is optional - only import if dependencies are available
46
+ try:
47
+ from .visualize import plot_route_map # noqa: F401
48
+
49
+ __all__.append("plot_route_map")
50
+ except ImportError:
51
+ pass
Binary file
Binary file
Binary file
bloomr/download.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ Graph downloading functionality using OSMnx.
3
+
4
+ This module provides functions to download road network data from OpenStreetMap
5
+ and save it as GraphML files for use with the Bloomr CPP solver.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Optional, Union
10
+
11
+ try:
12
+ import osmnx as ox
13
+ except ImportError as e:
14
+ raise ImportError(
15
+ "OSMnx is required for downloading graphs. " "Install with: pip install bloomr"
16
+ ) from e
17
+
18
+
19
+ def sanitize_filename(name: str) -> str:
20
+ """
21
+ Convert a place name to a valid filename.
22
+
23
+ Args:
24
+ name: Place name (e.g., "Jersey, Channel Islands")
25
+
26
+ Returns:
27
+ Sanitized filename (e.g., "jersey_channel_islands")
28
+ """
29
+ return name.lower().replace(" ", "_").replace(",", "").replace("/", "_")
30
+
31
+
32
+ def download_graph(
33
+ place: str,
34
+ *,
35
+ output_dir: Optional[Union[Path, str]] = None,
36
+ network_type: str = "drive_service",
37
+ simplify: bool = False,
38
+ force: bool = False,
39
+ ) -> Path:
40
+ """
41
+ Download road network from OpenStreetMap and save as GraphML.
42
+
43
+ This function downloads a road network for a specified place using OSMnx,
44
+ adds edge speeds and travel times, and saves it as a GraphML file that
45
+ can be used with the Bloomr CPP solver.
46
+
47
+ Args:
48
+ place: OSMnx place query (e.g., "Jersey, Channel Islands",
49
+ "San Francisco, California")
50
+ output_dir: Output directory for GraphML file. Defaults to "graphml_data"
51
+ network_type: Type of network to download. Options:
52
+ - "drive": Car-navigable roads
53
+ - "drive_service": Drive + service roads (default)
54
+ - "walk": Walkable paths
55
+ - "bike": Bikeable paths
56
+ - "all": All streets and paths
57
+ simplify: Whether to simplify the graph by merging nodes and removing
58
+ interstitial nodes. Default False.
59
+ force: Force re-download even if file exists. Default False.
60
+
61
+ Returns:
62
+ Path to the saved GraphML file
63
+
64
+ Raises:
65
+ ValueError: If the place cannot be found or network_type is invalid
66
+ RuntimeError: If the download or save operation fails
67
+
68
+ Example:
69
+ >>> from bloomr import download_graph
70
+ >>> graph_path = download_graph("Jersey, Channel Islands")
71
+ >>> print(graph_path)
72
+ graphml_data/jersey_channel_islands.graphml
73
+ """
74
+ # Set default output directory
75
+ if output_dir is None:
76
+ output_dir = Path("graphml_data")
77
+ else:
78
+ output_dir = Path(output_dir)
79
+
80
+ # Create output directory
81
+ output_dir.mkdir(parents=True, exist_ok=True)
82
+
83
+ # Generate filename
84
+ filename = sanitize_filename(place)
85
+ graphml_path = output_dir / f"{filename}.graphml"
86
+
87
+ # Check if file exists
88
+ if graphml_path.exists() and not force:
89
+ print(f"GraphML file already exists: {graphml_path}")
90
+ print("Use force=True to re-download")
91
+ return graphml_path
92
+
93
+ # Configure OSMnx
94
+ ox.settings.use_cache = True
95
+ ox.settings.log_console = False
96
+
97
+ print(f"Downloading road network for: {place}")
98
+ print(f"Network type: {network_type}")
99
+ print("This may take several minutes for large regions...")
100
+
101
+ try:
102
+ # Download graph from OpenStreetMap
103
+ G = ox.graph_from_place(place, network_type=network_type, simplify=simplify)
104
+
105
+ print(f"Downloaded: {len(G.nodes)} nodes, {len(G.edges)} edges")
106
+
107
+ # Add speeds and travel times to edges
108
+ print("Adding edge speeds and travel times...")
109
+ G = ox.add_edge_speeds(G)
110
+ G = ox.add_edge_travel_times(G)
111
+
112
+ # Save to GraphML format
113
+ print(f"Saving to: {graphml_path}")
114
+ ox.save_graphml(G, filepath=graphml_path)
115
+
116
+ print(f"Successfully saved GraphML file: {graphml_path}")
117
+ return graphml_path
118
+
119
+ except Exception as e:
120
+ raise RuntimeError(f"Failed to download or save graph: {e}") from e
bloomr/solver.py ADDED
@@ -0,0 +1,375 @@
1
+ """
2
+ Chinese Postman Problem solver using Rust blossom algorithm.
3
+
4
+ This module provides the main solver function that interfaces with the Rust
5
+ backend implementation of the blossom algorithm for efficient perfect matching.
6
+ """
7
+
8
+ import json
9
+ import platform
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Optional, Union
14
+
15
+ from .download import download_graph, sanitize_filename
16
+
17
+
18
+ def _get_platform_suffix() -> str:
19
+ """
20
+ Get the platform-specific suffix for the Rust binary.
21
+
22
+ Returns:
23
+ Platform suffix like 'linux-x86_64', 'macos-aarch64', etc.
24
+
25
+ Raises:
26
+ RuntimeError: If the platform is not supported
27
+ """
28
+ system = platform.system().lower()
29
+ machine = platform.machine().lower()
30
+
31
+ # Normalize machine architecture
32
+ if machine in ("x86_64", "amd64"):
33
+ arch = "x86_64"
34
+ elif machine in ("arm64", "aarch64"):
35
+ arch = "aarch64"
36
+ else:
37
+ raise RuntimeError(f"Unsupported architecture: {machine}")
38
+
39
+ # Map to platform suffixes
40
+ if system == "linux":
41
+ return f"linux-{arch}"
42
+ elif system == "darwin":
43
+ return f"macos-{arch}"
44
+ elif system == "windows":
45
+ return f"windows-{arch}"
46
+ else:
47
+ raise RuntimeError(f"Unsupported platform: {system}")
48
+
49
+
50
+ def _find_rust_binary() -> Path:
51
+ """
52
+ Find the Rust binary in either the packaged location or development location.
53
+
54
+ Tries in order:
55
+ 1. Packaged binary in bloomr/bin/ (platform-specific)
56
+ 2. Development release build in bloomr-rust/target/release/
57
+ 3. Development debug build in bloomr-rust/target/debug/
58
+
59
+ Returns:
60
+ Path to the Rust binary
61
+
62
+ Raises:
63
+ RuntimeError: If binary cannot be found
64
+ """
65
+ package_dir = Path(__file__).parent
66
+ project_root = package_dir.parent
67
+
68
+ # Try packaged binary first
69
+ try:
70
+ platform_suffix = _get_platform_suffix()
71
+ binary_name = f"bloomr-rust-{platform_suffix}"
72
+ if sys.platform == "win32":
73
+ binary_name += ".exe"
74
+
75
+ packaged_binary = package_dir / "bin" / binary_name
76
+ if packaged_binary.exists():
77
+ return packaged_binary
78
+ except RuntimeError:
79
+ # Platform not supported for packaged binary, fall through to dev builds
80
+ pass
81
+
82
+ # Try development builds
83
+ binary_name = "bloomr-rust.exe" if sys.platform == "win32" else "bloomr-rust"
84
+
85
+ # Try release build
86
+ dev_release_binary = project_root / "bloomr-rust" / "target" / "release" / binary_name
87
+ if dev_release_binary.exists():
88
+ return dev_release_binary
89
+
90
+ # Try debug build
91
+ dev_debug_binary = project_root / "bloomr-rust" / "target" / "debug" / binary_name
92
+ if dev_debug_binary.exists():
93
+ return dev_debug_binary
94
+
95
+ raise RuntimeError("Rust binary not found in any expected location")
96
+
97
+
98
+ class CPPResult:
99
+ """
100
+ Results from solving the Chinese Postman Problem.
101
+
102
+ Attributes:
103
+ graphml_path: Path to the input GraphML file
104
+ gpx_path: Path to the generated GPX route file
105
+ metrics_path: Path to the metrics JSON file
106
+ map_path: Path to the route visualization PNG (if generated)
107
+ metrics: Dictionary containing solution metrics
108
+ region: Region name
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ graphml_path: Path,
114
+ gpx_path: Path,
115
+ metrics_path: Path,
116
+ metrics: dict,
117
+ region: str,
118
+ map_path: Optional[Path] = None,
119
+ ):
120
+ self.graphml_path = graphml_path
121
+ self.gpx_path = gpx_path
122
+ self.metrics_path = metrics_path
123
+ self.map_path = map_path
124
+ self.metrics = metrics
125
+ self.region = region
126
+
127
+ def __repr__(self) -> str:
128
+ return (
129
+ f"CPPResult(region='{self.region}', "
130
+ f"distance={self.metrics.get('total_circuit_distance_km', 0):.2f}km, "
131
+ f"efficiency={self.metrics.get('distance_efficiency', 0):.4f})"
132
+ )
133
+
134
+ def summary(self) -> str:
135
+ """
136
+ Get a formatted summary of the solution.
137
+
138
+ Returns:
139
+ Multi-line string with key metrics
140
+ """
141
+ m = self.metrics
142
+ map_line = f" Map: {self.map_path}\n" if self.map_path else ""
143
+
144
+ unique_segments = m.get("unique_road_segments", 0)
145
+ base_edges = m.get("base_graph_edges", 0)
146
+ circuit_edges = m.get("circuit_length", 0)
147
+ bidirectional = m.get("bidirectional_edge_pairs", 0)
148
+ oneway = m.get("one_way_edges", 0)
149
+
150
+ orig_dist = m.get("total_original_distance_km", 0)
151
+ circuit_dist = m.get("total_circuit_distance_km", 0)
152
+ extra_dist = circuit_dist - orig_dist
153
+ actual_dup = m.get("duplication_ratio", 0)
154
+
155
+ dist_eff = m.get("distance_efficiency", 0)
156
+ time_eff = m.get("time_efficiency", 0)
157
+ deadhead = m.get("deadhead_percentage", 0)
158
+
159
+ return f"""
160
+ Bloomr CPP Solution (Mixed CPP) for {self.region}
161
+ {'='*70}
162
+
163
+ GRAPH STRUCTURE:
164
+ Unique road segments: {unique_segments}
165
+ - Bidirectional (two-way): {bidirectional}
166
+ - One-way: {oneway}
167
+ Base graph edges: {base_edges}
168
+ (bidirectional edges stored in both directions for Mixed CPP)
169
+
170
+ SOLUTION METRICS:
171
+ Duplication ratio: {actual_dup:.4f}
172
+ - How many times each road is traversed on average
173
+ - Ratio: circuit_edges / unique_segments
174
+ - {circuit_edges} / {unique_segments} = {actual_dup:.4f}
175
+
176
+ Total original distance: {orig_dist:.2f} km (all roads once)
177
+ Total circuit distance: {circuit_dist:.2f} km (actual route)
178
+ Extra distance needed: {extra_dist:.2f} km ({deadhead:.1f}%)
179
+
180
+ Distance efficiency: {dist_eff * 100:.1f}%
181
+ Time efficiency: {time_eff * 100:.1f}%
182
+
183
+ OUTPUT FILES:
184
+ GPX route: {self.gpx_path}
185
+ Metrics: {self.metrics_path}
186
+ {map_line}{'='*70}
187
+ """
188
+
189
+
190
+ def solve_cpp(
191
+ place: Optional[str] = None,
192
+ *,
193
+ graphml_path: Optional[Union[Path, str]] = None,
194
+ output_dir: Optional[Union[Path, str]] = None,
195
+ network_type: str = "drive_service",
196
+ simplify: bool = False,
197
+ method: str = "blossom",
198
+ verbose: bool = False,
199
+ visualize: bool = True,
200
+ ) -> CPPResult:
201
+ """
202
+ Solve the Chinese Postman Problem for a road network.
203
+
204
+ This function finds an optimal route that traverses every road segment at least once,
205
+ using the Rust implementation for efficient perfect matching.
206
+
207
+ You can either:
208
+ 1. Provide a place name, and the graph will be downloaded automatically
209
+ 2. Provide a path to an existing GraphML file
210
+
211
+ Args:
212
+ place: OSMnx place query (e.g., "Jersey, Channel Islands").
213
+ If provided, the graph will be downloaded automatically.
214
+ graphml_path: Path to an existing GraphML file from OSMnx.
215
+ If provided, this takes precedence over place.
216
+ output_dir: Output directory for solution files (GPX, metrics).
217
+ Defaults to "solutions/<region_name>"
218
+ network_type: Type of network to download if place is provided.
219
+ Options: "drive", "drive_service", "walk", "bike", "all"
220
+ simplify: Whether to simplify the downloaded graph. Only used if place is provided.
221
+ method: Graph balancing algorithm to use.
222
+ Only "blossom" is supported: Optimal O(n³) algorithm using Edmonds' blossom
223
+ algorithm for minimum weight perfect matching.
224
+ verbose: Enable verbose logging from the Rust solver
225
+ visualize: Whether to generate a map visualization PNG.
226
+ Requires matplotlib and contextily (install with: pip install 'bloomr[viz]')
227
+
228
+ Returns:
229
+ CPPResult object containing paths to output files and solution metrics
230
+
231
+ Raises:
232
+ ValueError: If neither place nor graphml_path is provided, or if both are provided
233
+ FileNotFoundError: If graphml_path is provided but doesn't exist
234
+ RuntimeError: If the Rust solver fails
235
+
236
+ Examples:
237
+ >>> from bloomr import solve_cpp
238
+ >>>
239
+ >>> # Solve for a place (downloads graph automatically)
240
+ >>> result = solve_cpp("Jersey, Channel Islands")
241
+ >>> print(result.summary())
242
+ >>>
243
+ >>> # Solve for an existing GraphML file
244
+ >>> result = solve_cpp(graphml_path="my_graph.graphml")
245
+ >>> print(f"Route saved to: {result.gpx_path}")
246
+ """
247
+ # Validate inputs
248
+ if place is None and graphml_path is None:
249
+ raise ValueError("Either 'place' or 'graphml_path' must be provided")
250
+
251
+ if place is not None and graphml_path is not None:
252
+ raise ValueError("Provide either 'place' or 'graphml_path', not both")
253
+
254
+ # Validate method
255
+ if method != "blossom":
256
+ raise ValueError(f"Invalid method '{method}'. Only 'blossom' is supported")
257
+
258
+ # Determine region name
259
+ if place is not None:
260
+ region = sanitize_filename(place)
261
+ # Download graph
262
+ print(f"Downloading graph for: {place}")
263
+ graphml_path = download_graph(
264
+ place,
265
+ network_type=network_type,
266
+ simplify=simplify,
267
+ )
268
+ else:
269
+ assert graphml_path is not None # Type narrowing for mypy
270
+ graphml_path = Path(graphml_path)
271
+ if not graphml_path.exists():
272
+ raise FileNotFoundError(f"GraphML file not found: {graphml_path}")
273
+ region = graphml_path.stem
274
+
275
+ # Set default output directory
276
+ if output_dir is None:
277
+ output_dir = Path("solutions") / region
278
+ else:
279
+ output_dir = Path(output_dir)
280
+
281
+ output_dir.mkdir(parents=True, exist_ok=True)
282
+
283
+ # Find Rust binary
284
+ rust_binary = _find_rust_binary()
285
+
286
+ if not rust_binary.exists():
287
+ raise RuntimeError(
288
+ "Rust binary not found. Please ensure bloomr is installed correctly.\n"
289
+ "If you're developing locally, build it first:\n"
290
+ " cd bloomr-rust\n"
291
+ " cargo build --release"
292
+ )
293
+
294
+ # Prepare command
295
+ cmd = [
296
+ str(rust_binary),
297
+ "--graphml",
298
+ str(graphml_path),
299
+ "--output-dir",
300
+ str(output_dir),
301
+ "--region",
302
+ region,
303
+ "--method",
304
+ method,
305
+ ]
306
+
307
+ if verbose:
308
+ cmd.append("--verbose")
309
+
310
+ # Run Rust solver
311
+ print("Running Bloomr CPP solver...")
312
+ print(f"Using graph: {graphml_path}")
313
+ print(f"Output directory: {output_dir}")
314
+ print() # Blank line before Rust output
315
+
316
+ try:
317
+ # Run without capturing output so progress bars show in real-time
318
+ subprocess.run(
319
+ cmd,
320
+ check=True,
321
+ )
322
+
323
+ except subprocess.CalledProcessError as e:
324
+ raise RuntimeError(f"Rust solver failed with exit code {e.returncode}") from e
325
+
326
+ # Load results
327
+ gpx_path = output_dir / f"{region}_cpp_route.gpx"
328
+ metrics_path = output_dir / f"{region}_metrics.json"
329
+
330
+ if not gpx_path.exists():
331
+ raise RuntimeError(f"Expected GPX file not found: {gpx_path}")
332
+
333
+ if not metrics_path.exists():
334
+ raise RuntimeError(f"Expected metrics file not found: {metrics_path}")
335
+
336
+ # Load metrics
337
+ with open(metrics_path, "r") as f:
338
+ metrics = json.load(f)
339
+
340
+ # Generate visualization if requested
341
+ map_path = None
342
+ if visualize:
343
+ try:
344
+ from .visualize import plot_route_map
345
+
346
+ map_path = output_dir / f"{region}_route_map.png"
347
+ print("\nGenerating route visualization...")
348
+ plot_route_map(
349
+ graphml_path=graphml_path,
350
+ gpx_path=gpx_path,
351
+ output_path=map_path,
352
+ )
353
+ except ImportError:
354
+ print(
355
+ "\nWarning: Could not generate visualization. "
356
+ "Install visualization dependencies with:\n"
357
+ " pip install 'bloomr[viz]'"
358
+ )
359
+ except Exception as e:
360
+ print(f"\nWarning: Could not generate visualization: {e}")
361
+
362
+ # Create result object
363
+ cpp_result = CPPResult(
364
+ graphml_path=graphml_path,
365
+ gpx_path=gpx_path,
366
+ metrics_path=metrics_path,
367
+ metrics=metrics,
368
+ region=region,
369
+ map_path=map_path,
370
+ )
371
+
372
+ print("\nSolution completed successfully!")
373
+ print(cpp_result.summary())
374
+
375
+ return cpp_result
bloomr/visualize.py ADDED
@@ -0,0 +1,239 @@
1
+ """
2
+ Visualization utilities for CPP solutions.
3
+
4
+ This module provides functions to visualize CPP routes on maps.
5
+ """
6
+
7
+ import xml.etree.ElementTree as ET
8
+ from pathlib import Path
9
+ from typing import List, Tuple
10
+
11
+ import networkx as nx
12
+
13
+
14
+ def plot_route_map(
15
+ graphml_path: Path,
16
+ gpx_path: Path,
17
+ output_path: Path,
18
+ *,
19
+ figsize: Tuple[int, int] = (15, 15),
20
+ show_basemap: bool = True,
21
+ route_color: str = "#FF0000",
22
+ route_alpha: float = 0.6,
23
+ route_width: float = 1.5,
24
+ network_color: str = "#CCCCCC",
25
+ network_alpha: float = 0.3,
26
+ network_width: float = 0.5,
27
+ dpi: int = 150,
28
+ ) -> Path:
29
+ """
30
+ Create a map visualization of the CPP route.
31
+
32
+ This function reads the GraphML road network and GPX route file,
33
+ then creates a visualization showing the route overlaid on the road network
34
+ with an optional basemap.
35
+
36
+ Args:
37
+ graphml_path: Path to the GraphML file containing the road network
38
+ gpx_path: Path to the GPX file containing the route
39
+ output_path: Path where the PNG image will be saved
40
+ figsize: Figure size in inches (width, height)
41
+ show_basemap: Whether to show a basemap (requires contextily)
42
+ route_color: Color for the route line
43
+ route_alpha: Transparency of the route line (0-1)
44
+ route_width: Width of the route line
45
+ network_color: Color for the underlying road network
46
+ network_alpha: Transparency of the road network (0-1)
47
+ network_width: Width of the road network lines
48
+ dpi: Dots per inch for the output image
49
+
50
+ Returns:
51
+ Path to the saved PNG file
52
+
53
+ Raises:
54
+ ImportError: If required visualization dependencies are not installed
55
+ FileNotFoundError: If input files don't exist
56
+
57
+ Examples:
58
+ >>> from bloomr.visualize import plot_route_map
59
+ >>> from pathlib import Path
60
+ >>>
61
+ >>> plot_route_map(
62
+ ... graphml_path=Path("graphml_data/jersey.graphml"),
63
+ ... gpx_path=Path("solutions/jersey/jersey_cpp_route.gpx"),
64
+ ... output_path=Path("solutions/jersey/jersey_route_map.png"),
65
+ ... )
66
+ """
67
+ try:
68
+ import matplotlib.patches as mpatches
69
+ import matplotlib.pyplot as plt
70
+ except ImportError as e:
71
+ raise ImportError(
72
+ "Visualization requires matplotlib. Install with:\n" " pip install 'bloomr[viz]'"
73
+ ) from e
74
+
75
+ if show_basemap:
76
+ try:
77
+ import contextily as ctx
78
+ except ImportError as e:
79
+ raise ImportError(
80
+ "Basemap visualization requires contextily. Install with:\n"
81
+ " pip install 'bloomr[viz]'"
82
+ ) from e
83
+
84
+ # Verify input files exist
85
+ if not graphml_path.exists():
86
+ raise FileNotFoundError(f"GraphML file not found: {graphml_path}")
87
+ if not gpx_path.exists():
88
+ raise FileNotFoundError(f"GPX file not found: {gpx_path}")
89
+
90
+ # Load the road network
91
+ print(f"Loading road network from {graphml_path.name}...")
92
+ G = nx.read_graphml(graphml_path)
93
+
94
+ # Extract coordinates from the graph
95
+ node_positions = {}
96
+ for node, data in G.nodes(data=True):
97
+ try:
98
+ lon = float(data.get("x", data.get("d4", 0)))
99
+ lat = float(data.get("y", data.get("d3", 0)))
100
+ node_positions[node] = (lon, lat)
101
+ except (ValueError, TypeError):
102
+ continue
103
+
104
+ # Parse GPX file to get route coordinates
105
+ print(f"Loading route from {gpx_path.name}...")
106
+ route_coords = _parse_gpx(gpx_path)
107
+
108
+ if not route_coords:
109
+ raise ValueError(f"No route coordinates found in GPX file: {gpx_path}")
110
+
111
+ print(f"Creating visualization with {len(route_coords)} route points...")
112
+
113
+ # Create figure
114
+ fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
115
+
116
+ # Plot road network edges
117
+ for u, v in G.edges():
118
+ if u in node_positions and v in node_positions:
119
+ x_coords = [node_positions[u][0], node_positions[v][0]]
120
+ y_coords = [node_positions[u][1], node_positions[v][1]]
121
+ ax.plot(
122
+ x_coords,
123
+ y_coords,
124
+ color=network_color,
125
+ alpha=network_alpha,
126
+ linewidth=network_width,
127
+ zorder=1,
128
+ )
129
+
130
+ # Plot route
131
+ route_lons = [coord[0] for coord in route_coords]
132
+ route_lats = [coord[1] for coord in route_coords]
133
+ ax.plot(
134
+ route_lons,
135
+ route_lats,
136
+ color=route_color,
137
+ alpha=route_alpha,
138
+ linewidth=route_width,
139
+ zorder=2,
140
+ label="CPP Route",
141
+ )
142
+
143
+ # Mark start/end points
144
+ if route_coords:
145
+ start = route_coords[0]
146
+ end = route_coords[-1]
147
+ ax.plot(start[0], start[1], "go", markersize=10, zorder=3, label="Start")
148
+ ax.plot(end[0], end[1], "rs", markersize=10, zorder=3, label="End")
149
+
150
+ # Add basemap if requested
151
+ if show_basemap:
152
+ try:
153
+ print("Adding basemap (this may take a moment)...")
154
+ ctx.add_basemap(
155
+ ax,
156
+ crs="EPSG:4326",
157
+ source=ctx.providers.CartoDB.Positron,
158
+ attribution=False,
159
+ )
160
+ except Exception as e:
161
+ print(f"Warning: Could not add basemap: {e}")
162
+ print("Continuing without basemap...")
163
+
164
+ # Set labels and title
165
+ ax.set_xlabel("Longitude", fontsize=12)
166
+ ax.set_ylabel("Latitude", fontsize=12)
167
+ ax.set_title("Chinese Postman Problem Solution", fontsize=16, fontweight="bold")
168
+
169
+ # Add legend
170
+ legend_elements = [
171
+ mpatches.Patch(color=route_color, alpha=route_alpha, label="CPP Route"),
172
+ mpatches.Patch(color=network_color, alpha=network_alpha, label="Road Network"),
173
+ plt.Line2D(
174
+ [0], [0], marker="o", color="w", markerfacecolor="g", markersize=8, label="Start"
175
+ ),
176
+ plt.Line2D([0], [0], marker="s", color="w", markerfacecolor="r", markersize=8, label="End"),
177
+ ]
178
+ ax.legend(handles=legend_elements, loc="upper right", fontsize=10)
179
+
180
+ # Adjust layout and save
181
+ plt.tight_layout()
182
+ output_path.parent.mkdir(parents=True, exist_ok=True)
183
+ print(f"Saving map to {output_path}...")
184
+ plt.savefig(output_path, dpi=dpi, bbox_inches="tight")
185
+ plt.close()
186
+
187
+ print(f"Map saved successfully: {output_path}")
188
+ return output_path
189
+
190
+
191
+ def _parse_gpx(gpx_path: Path) -> List[Tuple[float, float]]:
192
+ """
193
+ Parse GPX file and extract route coordinates.
194
+
195
+ Args:
196
+ gpx_path: Path to GPX file
197
+
198
+ Returns:
199
+ List of (lon, lat) tuples
200
+ """
201
+ # GPX files are generated by our own Rust backend, not untrusted external sources
202
+ tree = ET.parse(gpx_path) # nosec B314
203
+ root = tree.getroot()
204
+
205
+ # GPX namespace
206
+ ns = {"gpx": "http://www.topografix.com/GPX/1/1"}
207
+
208
+ coords = []
209
+
210
+ # Try to find track points
211
+ for trkpt in root.findall(".//gpx:trkpt", ns):
212
+ lat_str = trkpt.get("lat")
213
+ lon_str = trkpt.get("lon")
214
+ if lat_str is not None and lon_str is not None:
215
+ lat = float(lat_str)
216
+ lon = float(lon_str)
217
+ coords.append((lon, lat))
218
+
219
+ # If no track points, try route points
220
+ if not coords:
221
+ for rtept in root.findall(".//gpx:rtept", ns):
222
+ lat_str = rtept.get("lat")
223
+ lon_str = rtept.get("lon")
224
+ if lat_str is not None and lon_str is not None:
225
+ lat = float(lat_str)
226
+ lon = float(lon_str)
227
+ coords.append((lon, lat))
228
+
229
+ # If still no points, try waypoints
230
+ if not coords:
231
+ for wpt in root.findall(".//gpx:wpt", ns):
232
+ lat_str = wpt.get("lat")
233
+ lon_str = wpt.get("lon")
234
+ if lat_str is not None and lon_str is not None:
235
+ lat = float(lat_str)
236
+ lon = float(lon_str)
237
+ coords.append((lon, lat))
238
+
239
+ return coords
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: bloomr
3
+ Version: 0.1.1
4
+ Summary: Memory-efficient Chinese Postman Problem solver using Rust blossom algorithm
5
+ Author-email: Nathaniel Hey <gnathoi@nat.je>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/gnathoi/bloomr
8
+ Project-URL: Documentation, https://github.com/gnathoi/bloomr#readme
9
+ Project-URL: Repository, https://github.com/gnathoi/bloomr
10
+ Project-URL: Issues, https://github.com/gnathoi/bloomr/issues
11
+ Keywords: chinese-postman,graph-algorithms,routing,osmnx,blossom-algorithm,eulerian-circuit
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Rust
20
+ Classifier: Topic :: Scientific/Engineering :: GIS
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: osmnx>=1.9.0
26
+ Requires-Dist: networkx>=3.0
27
+ Provides-Extra: viz
28
+ Requires-Dist: matplotlib>=3.5.0; extra == "viz"
29
+ Requires-Dist: contextily>=1.3.0; extra == "viz"
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=7.0; extra == "dev"
32
+ Requires-Dist: black>=23.0; extra == "dev"
33
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
34
+ Requires-Dist: mypy>=1.0; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # Bloomr
38
+
39
+ [![CI](https://github.com/gnathoi/bloomr/actions/workflows/ci.yml/badge.svg)](https://github.com/gnathoi/bloomr/actions/workflows/ci.yml)
40
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
41
+ [![PyPI version](https://img.shields.io/pypi/v/bloomr.svg)](https://pypi.org/project/bloomr/)
42
+ [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
43
+ [![Made with Rust](https://img.shields.io/badge/Made%20with-Rust-orange.svg)](https://www.rust-lang.org/)
44
+
45
+ A Python package for solving the Mixed Chinese Postman Problem on road networks with a high-performance Rust backend.
46
+
47
+ ## What is the Mixed Chinese Postman Problem?
48
+
49
+ The Chinese Postman Problem asks: what is the shortest route that traverses every edge in a graph at least once? This is useful for planning routes that need to, for example, survey every road in an area as efficiently as possible.
50
+
51
+ The Mixed CPP extends this to real-world road networks where:
52
+
53
+ - Some streets are bidirectional (two-way streets that can be traversed in either direction)
54
+ - Some streets are directed (one-way streets with mandatory direction)
55
+
56
+ Finding optimal routes in mixed networks is more complex than the classical all-directed or all-undirected versions of the problem. Bloomr solves this using Edmonds' blossom algorithm for efficient minimum-weight perfect matching.
57
+
58
+ ## What Bloomr Does
59
+
60
+ Bloomr downloads real road network data from OpenStreetMap, identifies one-way and two-way streets, computes an optimal route that covers every street at least once, and outputs the solution as a GPX file for GPS navigation.
61
+
62
+ The package uses a Python API for ease of use and a Rust backend for computational performance. The Rust implementation uses the blossom algorithm to find perfect matchings that minimize the total distance of repeated streets.
63
+
64
+ ## Installation
65
+
66
+ ```bash
67
+ pip install bloomr
68
+ ```
69
+
70
+ ## Python API
71
+
72
+ The API provides two main functions:
73
+
74
+ ### Solve CPP for a location
75
+
76
+ ```python
77
+ from bloomr import solve_cpp
78
+
79
+ # Download road network and solve CPP
80
+ result = solve_cpp("Jersey, Channel Islands")
81
+
82
+ # View solution summary
83
+ print(result.summary())
84
+
85
+ # Access result files
86
+ print(f"Route saved to: {result.gpx_path}")
87
+ print(f"Metrics: {result.metrics}")
88
+ ```
89
+
90
+ ### Download a graph separately
91
+
92
+ ```python
93
+ from bloomr import download_graph
94
+
95
+ # Download and cache a road network
96
+ graph_path = download_graph("San Francisco, California")
97
+
98
+ # Solve using the cached graph
99
+ result = solve_cpp(graphml_path=graph_path)
100
+ ```
101
+
102
+ ## API Reference
103
+
104
+ ### solve_cpp()
105
+
106
+ ```python
107
+ solve_cpp(
108
+ place: Optional[str] = None,
109
+ *,
110
+ graphml_path: Optional[Path] = None,
111
+ output_dir: Optional[Path] = None,
112
+ network_type: str = "drive_service",
113
+ simplify: bool = False,
114
+ method: str = "blossom",
115
+ verbose: bool = False,
116
+ visualize: bool = True
117
+ )
118
+ ```
119
+
120
+ Solve the Chinese Postman Problem for a road network.
121
+
122
+ Arguments:
123
+
124
+ - `place`: Location name for OSMnx (e.g., "Jersey, Channel Islands")
125
+ - `graphml_path`: Path to existing GraphML file (alternative to place)
126
+ - `output_dir`: Directory for output files (default: "solutions/{region}")
127
+ - `network_type`: Type of road network - "drive", "drive_service", "walk", "bike", or "all"
128
+ - `simplify`: Whether to simplify the graph topology
129
+ - `method`: Graph balancing algorithm - "blossom" (optimal, default)
130
+ - `verbose`: Print detailed progress information
131
+ - `visualize`: Generate a route visualization map
132
+
133
+ Returns a `CPPResult` object containing:
134
+
135
+ - `graphml_path`: Input graph file
136
+ - `gpx_path`: Output GPX route file
137
+ - `metrics_path`: JSON file with solution metrics
138
+ - `metrics`: Dictionary of solution metrics
139
+ - `region`: Region name
140
+ - `map_path`: Path to visualization (if generated)
141
+
142
+ ### download_graph()
143
+
144
+ ```python
145
+ download_graph(
146
+ place: str,
147
+ *,
148
+ output_dir: Optional[Path] = None,
149
+ network_type: str = "drive_service",
150
+ simplify: bool = False,
151
+ force: bool = False
152
+ ) -> Path
153
+ ```
154
+
155
+ Download a road network from OpenStreetMap and save as GraphML.
156
+
157
+ Arguments:
158
+
159
+ - `place`: Location name for OSMnx
160
+ - `output_dir`: Directory for GraphML file (default: "graphml_data")
161
+ - `network_type`: Type of road network
162
+ - `simplify`: Whether to simplify the graph topology
163
+ - `force`: Force re-download even if cached
164
+
165
+ Returns the path to the saved GraphML file.
166
+
167
+ ## Solution Metrics
168
+
169
+ Each solution includes comprehensive metrics:
170
+
171
+ - `unique_road_segments`: Total number of distinct streets
172
+ - `bidirectional_edge_pairs`: Number of two-way streets
173
+ - `one_way_edges`: Number of one-way streets
174
+ - `duplication_ratio`: Average times each street is traversed
175
+ - `total_original_distance_km`: Total length of all streets (traversed once)
176
+ - `total_circuit_distance_km`: Actual route distance
177
+ - `distance_efficiency`: Ratio of original to circuit distance
178
+ - `deadhead_percentage`: Percentage of route that repeats streets
179
+
180
+ ## Output Files
181
+
182
+ All outputs are stored in `solutions/{region_name}/`:
183
+
184
+ - `{region}_cpp_route.gpx`: GPS route file for navigation
185
+ - `{region}_metrics.json`: Detailed solution metrics
186
+
187
+ Graphs are cached in `graphml_data/` to avoid re-downloading.
@@ -0,0 +1,13 @@
1
+ bloomr/__init__.py,sha256=Y1VohmfGPdw0v7mHAG-0-6FliOWY5h-tBnqs-T8AZ38,1463
2
+ bloomr/download.py,sha256=fZA1SJ0BTg9iwtA8lErLSm2Rep-m4aiCa2gdCSoMHLw,3920
3
+ bloomr/solver.py,sha256=FRi7UnZGUjwe7If_2OOLCZQaGGUBymHDKt57jw6osMY,12180
4
+ bloomr/visualize.py,sha256=02G9wg2xwloKQUNlTplFza8O0ASZgTBv927EAgioEic,7874
5
+ bloomr/bin/bloomr-rust-linux-x86_64,sha256=Mhr_lRkgcLZpI9xOqMYBYUKuZR7siCZC6_nYgu5-FLM,1636288
6
+ bloomr/bin/bloomr-rust-macos-aarch64,sha256=MK5ugHRdYN98MwT50lFzhEd9HuLQZgR07Ski6oakiyA,1388128
7
+ bloomr/bin/bloomr-rust-macos-x86_64,sha256=lHgs1Z-nzY9EU6NsIZl0zq8NU4TkzyDBCgn5DgPHsUY,1481368
8
+ bloomr/bin/bloomr-rust-windows-x86_64.exe,sha256=fMwEBUpaXe5uG7jE_v2Y3vPeRaqb77H_xIsjxyQLLrc,1349120
9
+ bloomr-0.1.1.dist-info/licenses/LICENSE,sha256=6kwgzDLrIVTZSRWSydWtFbAetQRjIqDhls-AqwA3Wes,11341
10
+ bloomr-0.1.1.dist-info/METADATA,sha256=9__lg6SNSzpVlVnZ8eS5lup819ckoMJJkP9Y3h1D_mk,6682
11
+ bloomr-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ bloomr-0.1.1.dist-info/top_level.txt,sha256=sL9zHoV1EwZQxZZouGQ2hUQMNl-a0QABft1-XKWdl94,7
13
+ bloomr-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Support. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2025 Nathaniel Hey
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1 @@
1
+ bloomr