netload 0.1.0__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.
netload/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """netload: a lightweight parser for PTV Visum ``.net`` files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .exceptions import (
6
+ VisumNetEncodingError,
7
+ VisumNetError,
8
+ VisumNetParseError,
9
+ )
10
+ from .network import Network
11
+ from .parser import parse_net, parse_net_with_encoding
12
+ from .table import Table
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = [
17
+ "Network",
18
+ "Table",
19
+ "VisumNetEncodingError",
20
+ "VisumNetError",
21
+ "VisumNetParseError",
22
+ "parse_net",
23
+ "read_net",
24
+ ]
25
+
26
+
27
+ def read_net(
28
+ path: str,
29
+ encoding: str | None = None,
30
+ sep: str = ";",
31
+ infer_types: bool = False,
32
+ ) -> Network:
33
+ """Read a PTV Visum ``.net`` file and return a :class:`Network`.
34
+
35
+ Parameters
36
+ ----------
37
+ path:
38
+ Path of the ``.net`` file.
39
+ encoding:
40
+ Optional explicit encoding. When omitted, the encoding is detected
41
+ automatically (BOM, then UTF-8, then Windows-1251).
42
+ sep:
43
+ Field separator used inside tables (default ``";"``).
44
+ infer_types:
45
+ When ``True``, apply a light numeric type inference on every column.
46
+ The default preserves the original string values.
47
+
48
+ Returns
49
+ -------
50
+ A :class:`Network` exposing every parsed table as a
51
+ ``pandas.DataFrame``.
52
+
53
+ Raises
54
+ ------
55
+ FileNotFoundError
56
+ If ``path`` does not exist.
57
+ netload.exceptions.VisumNetEncodingError
58
+ If the file cannot be decoded.
59
+ netload.exceptions.VisumNetParseError
60
+ If the file structure is invalid.
61
+ """
62
+ tables, resolved_encoding = parse_net_with_encoding(
63
+ path,
64
+ encoding=encoding,
65
+ sep=sep,
66
+ infer_types=infer_types,
67
+ )
68
+ return Network(tables, source=path, encoding=resolved_encoding)
netload/cli.py ADDED
@@ -0,0 +1,182 @@
1
+ """Command line interface for :mod:`netload`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import List, Optional
8
+
9
+
10
+ def build_parser() -> argparse.ArgumentParser:
11
+ parser = argparse.ArgumentParser(
12
+ prog="netload",
13
+ description=(
14
+ "Read and transform any PTV Visum/Simetra .net network file "
15
+ "into CSV, JSON or GeoJSON."
16
+ ),
17
+ )
18
+ parser.add_argument("input", help="Path to the .net file")
19
+ parser.add_argument(
20
+ "--list-tables",
21
+ action="store_true",
22
+ help="List the parsed tables with their fields (columns)",
23
+ )
24
+ parser.add_argument(
25
+ "--export-csv",
26
+ metavar="DIR",
27
+ help="Export every table to CSV files inside DIR",
28
+ )
29
+ parser.add_argument(
30
+ "--export-json",
31
+ metavar="DIR",
32
+ help="Export every table to JSON files inside DIR",
33
+ )
34
+ parser.add_argument(
35
+ "--export-geojson",
36
+ metavar="DIR",
37
+ help="Export tables with geometry to GeoJSON files inside DIR",
38
+ )
39
+ parser.add_argument(
40
+ "--tables",
41
+ metavar="NAME[,NAME...]",
42
+ help=(
43
+ "Limit the export to specific tables (comma-separated, e.g. "
44
+ "NODE,LINK). Default: all tables."
45
+ ),
46
+ )
47
+ parser.add_argument(
48
+ "--table",
49
+ metavar="NAME",
50
+ help="Select a single table to transform (used with --to-*)",
51
+ )
52
+ parser.add_argument(
53
+ "--to-csv",
54
+ metavar="FILE",
55
+ help="Write the selected table to a CSV file",
56
+ )
57
+ parser.add_argument(
58
+ "--to-json",
59
+ metavar="FILE",
60
+ help="Write the selected table to a JSON file",
61
+ )
62
+ parser.add_argument(
63
+ "--to-geojson",
64
+ metavar="FILE",
65
+ help="Write the selected table to a GeoJSON file",
66
+ )
67
+ parser.add_argument(
68
+ "--sep",
69
+ default=";",
70
+ help="Field separator used by the file and CSV export (default: ';')",
71
+ )
72
+ parser.add_argument(
73
+ "--encoding",
74
+ default=None,
75
+ help="Force a file encoding (default: auto-detect)",
76
+ )
77
+ return parser
78
+
79
+
80
+ def _print_summary(tables: dict) -> None:
81
+ print("Visum network loaded successfully")
82
+ print()
83
+ print("Tables:")
84
+ for name, table in tables.items():
85
+ print(f"- {name}: {len(table.df)} rows")
86
+
87
+
88
+ def _print_tables_with_fields(tables: dict) -> None:
89
+ print("Visum network loaded successfully")
90
+ print()
91
+ print("Tables and fields:")
92
+ for name, table in tables.items():
93
+ fields = ", ".join(table.df.columns) or "(no columns)"
94
+ print(f"- {name}: {len(table.df)} rows")
95
+ print(f" fields: {fields}")
96
+
97
+
98
+ def main(argv: Optional[List[str]] = None) -> int:
99
+ args = build_parser().parse_args(argv)
100
+
101
+ from .parser import parse_net
102
+
103
+ try:
104
+ tables = parse_net(args.input, encoding=args.encoding, sep=args.sep)
105
+ except FileNotFoundError:
106
+ print(f"error: file not found: {args.input}", file=sys.stderr)
107
+ return 2
108
+
109
+ from .network import Network
110
+
111
+ network = Network(tables, source=args.input, encoding="")
112
+
113
+ if args.table is not None:
114
+ return _transform_single_table(network, args)
115
+
116
+ if args.list_tables:
117
+ _print_tables_with_fields(network.tables)
118
+ else:
119
+ _print_summary(network.tables)
120
+
121
+ written = []
122
+ selected = None
123
+ if args.tables:
124
+ selected = [name.strip() for name in args.tables.split(",") if name.strip()]
125
+
126
+ if args.export_csv:
127
+ written += network.export_csv(args.export_csv, sep=args.sep, tables=selected)
128
+ print(f"\nExported {len(written)} CSV files to {args.export_csv}")
129
+ if args.export_json:
130
+ written += network.export_json(args.export_json, tables=selected)
131
+ print(f"\nExported {len(written)} JSON files to {args.export_json}")
132
+ if args.export_geojson:
133
+ written += network.export_geojson(args.export_geojson, tables=selected)
134
+ print(f"\nExported {len(written)} GeoJSON files to {args.export_geojson}")
135
+
136
+ return 0
137
+
138
+
139
+ def _transform_single_table(network: Network, args: argparse.Namespace) -> int:
140
+ from .exceptions import VisumNetError
141
+ from .exporters import export_table_csv, export_table_geojson, export_table_json
142
+
143
+ if args.table not in network:
144
+ print(
145
+ f"error: table {args.table!r} not found. Available: "
146
+ f"{', '.join(network.table_names)}",
147
+ file=sys.stderr,
148
+ )
149
+ return 2
150
+
151
+ targets = [
152
+ flag
153
+ for flag in ("to_csv", "to_json", "to_geojson")
154
+ if getattr(args, flag) is not None
155
+ ]
156
+ if not targets:
157
+ print("error: --table requires at least one of --to-csv/--to-json/--to-geojson",
158
+ file=sys.stderr)
159
+ return 2
160
+
161
+ df = network[args.table]
162
+ for flag in targets:
163
+ path = getattr(args, flag)
164
+ if flag == "to_csv":
165
+ export_table_csv(df, path, sep=args.sep)
166
+ elif flag == "to_json":
167
+ export_table_json(df, path)
168
+ else:
169
+ if not export_table_geojson(df, path):
170
+ print(
171
+ f"warning: table {args.table!r} has no geometry; "
172
+ f"GeoJSON not written",
173
+ file=sys.stderr,
174
+ )
175
+ continue
176
+ print(f"Exported table {args.table} -> {path}")
177
+
178
+ return 0
179
+
180
+
181
+ if __name__ == "__main__":
182
+ sys.exit(main())
netload/exceptions.py ADDED
@@ -0,0 +1,45 @@
1
+ """Public exceptions for :mod:`netload`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class VisumNetError(Exception):
9
+ """Base exception for all errors raised by :mod:`netload`."""
10
+
11
+
12
+ class VisumNetParseError(VisumNetError):
13
+ """Raised when a ``.net`` file cannot be parsed correctly.
14
+
15
+ Attributes
16
+ ----------
17
+ source:
18
+ Path of the file being parsed.
19
+ line:
20
+ Line number where the problem occurred (1-based), if known.
21
+ message:
22
+ Human readable description of the problem.
23
+ """
24
+
25
+ def __init__(self, source: str, message: str, line: Optional[int] = None) -> None:
26
+ self.source = source
27
+ self.line = line
28
+ self.message = message
29
+ location = f"{source}:{line}" if line is not None else source
30
+ super().__init__(f"{location}: {message}")
31
+
32
+
33
+ class VisumNetEncodingError(VisumNetError):
34
+ """Raised when the encoding of a ``.net`` file cannot be determined/decoded.
35
+
36
+ Attributes
37
+ ----------
38
+ source:
39
+ Path of the file being parsed.
40
+ """
41
+
42
+ def __init__(self, source: str, message: str) -> None:
43
+ self.source = source
44
+ self.message = message
45
+ super().__init__(f"{source}: {message}")
netload/exporters.py ADDED
@@ -0,0 +1,249 @@
1
+ """Export helpers: CSV, JSON and GeoJSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence
9
+
10
+ import pandas as pd
11
+
12
+ from .exceptions import VisumNetError
13
+ from .wkt import parse_wkt
14
+
15
+ if TYPE_CHECKING: # pragma: no cover
16
+ from .network import Network
17
+ from .table import Table
18
+
19
+ # Column names that are known to hold WKT geometry in .net files.
20
+ _WKT_COLUMNS = ("WKTSURFACE", "WKTPOLY", "GEOMETRY")
21
+
22
+
23
+ def _resolve_tables(
24
+ network: "Network", tables: Optional[Sequence[str]]
25
+ ) -> List["Table"]:
26
+ """Return the tables to export.
27
+
28
+ When ``tables`` is ``None`` every table is returned (file order);
29
+ otherwise only the requested table names (file order is preserved).
30
+ """
31
+ if tables is None:
32
+ return list(network.tables.values())
33
+
34
+ resolved: List["Table"] = []
35
+ for name in tables:
36
+ if name not in network.tables:
37
+ raise VisumNetError(
38
+ f"table {name!r} not found in the network "
39
+ f"(tables: {', '.join(network.table_names) or 'none'})"
40
+ )
41
+ resolved.append(network.tables[name])
42
+ return resolved
43
+
44
+
45
+ # -- small helpers ----------------------------------------------------------
46
+
47
+
48
+ def _records_to_jsonable(df: pd.DataFrame) -> List[Dict[str, Any]]:
49
+ """Convert a DataFrame to a list of JSON-safe records (NaN -> None)."""
50
+ object_frame = df.astype(object).where(pd.notna(df), None)
51
+ return object_frame.to_dict("records")
52
+
53
+
54
+ def _geometry_for_row(row: pd.Series, geometry_col: Optional[str]) -> Optional[Dict[str, Any]]:
55
+ if geometry_col is None:
56
+ return None
57
+ value = row.get(geometry_col)
58
+ if value is None or (isinstance(value, float) and pd.isna(value)):
59
+ return None
60
+ return parse_wkt(str(value))
61
+
62
+
63
+ # -- single-table exporters -------------------------------------------------
64
+
65
+
66
+ def export_table_csv(
67
+ df: pd.DataFrame,
68
+ path: str | os.PathLike[str],
69
+ sep: str = ";",
70
+ encoding: str = "utf-8",
71
+ ) -> None:
72
+ """Write one DataFrame to a CSV file."""
73
+ df.to_csv(
74
+ path,
75
+ sep=sep,
76
+ index=False,
77
+ encoding=encoding,
78
+ lineterminator="\n",
79
+ )
80
+
81
+
82
+ def export_table_json(
83
+ df: pd.DataFrame,
84
+ path: str | os.PathLike[str],
85
+ encoding: str = "utf-8",
86
+ ) -> None:
87
+ """Write one DataFrame to a JSON array of records."""
88
+ with open(path, "w", encoding=encoding) as handle:
89
+ json.dump(
90
+ _records_to_jsonable(df),
91
+ handle,
92
+ ensure_ascii=False,
93
+ indent=2,
94
+ )
95
+
96
+
97
+ def _pick_geometry_column(df: pd.DataFrame) -> Optional[str]:
98
+ columns = set(df.columns)
99
+ for candidate in _WKT_COLUMNS:
100
+ if candidate in columns:
101
+ return candidate
102
+ return None
103
+
104
+
105
+ def table_geometry_kind(df: pd.DataFrame) -> Optional[str]:
106
+ """Describe the geometry of a table.
107
+
108
+ Returns ``"WKT"`` when a WKT geometry column exists, ``"XY"`` when the
109
+ table has ``XCOORD``/``YCOORD`` columns, otherwise ``None``.
110
+ """
111
+ if _pick_geometry_column(df) is not None:
112
+ return "WKT"
113
+ if "XCOORD" in df.columns and "YCOORD" in df.columns:
114
+ return "XY"
115
+ return None
116
+
117
+
118
+ def export_table_geojson(
119
+ df: pd.DataFrame,
120
+ path: str | os.PathLike[str],
121
+ encoding: str = "utf-8",
122
+ ) -> bool:
123
+ """Write one DataFrame to a GeoJSON ``FeatureCollection``.
124
+
125
+ Geometry is taken from a WKT column (``WKTSURFACE``, ``WKTPOLY``,
126
+ ``GEOMETRY``) when available, otherwise from ``XCOORD``/``YCOORD`` as
127
+ Point features.
128
+
129
+ Returns ``False`` when the table has no geometry and nothing is written.
130
+ """
131
+ geometry_col = _pick_geometry_column(df)
132
+
133
+ if geometry_col is None and not (
134
+ "XCOORD" in df.columns and "YCOORD" in df.columns
135
+ ):
136
+ return False
137
+
138
+ geometry_keys = {geometry_col, "XCOORD", "YCOORD"} if geometry_col else {"XCOORD", "YCOORD"}
139
+ property_columns = [c for c in df.columns if c not in geometry_keys]
140
+
141
+ features: List[Dict[str, Any]] = []
142
+ for _, row in df.iterrows():
143
+ if geometry_col is not None:
144
+ geometry = _geometry_for_row(row, geometry_col)
145
+ else:
146
+ x = row.get("XCOORD")
147
+ y = row.get("YCOORD")
148
+ if x is None or y is None or pd.isna(x) or pd.isna(y):
149
+ geometry = None
150
+ else:
151
+ geometry = {"type": "Point", "coordinates": [float(x), float(y)]}
152
+ properties = {
153
+ col: row.get(col) for col in property_columns
154
+ }
155
+ for key in list(properties):
156
+ value = properties[key]
157
+ if value is not None and isinstance(value, float) and pd.isna(value):
158
+ properties[key] = None
159
+ features.append({"type": "Feature", "geometry": geometry, "properties": properties})
160
+
161
+ feature_collection: Dict[str, Any] = {
162
+ "type": "FeatureCollection",
163
+ "features": features,
164
+ }
165
+
166
+ with open(path, "w", encoding=encoding) as handle:
167
+ json.dump(feature_collection, handle, ensure_ascii=False, indent=2)
168
+
169
+ return True
170
+
171
+
172
+ # -- network-level exporters -------------------------------------------------
173
+
174
+
175
+ def export_tables(
176
+ network: "Network",
177
+ output_dir: str | os.PathLike[str],
178
+ sep: str = ";",
179
+ encoding: str = "utf-8",
180
+ skip_empty: bool = True,
181
+ tables: Optional[Sequence[str]] = None,
182
+ ) -> List[str]:
183
+ """Write one CSV file per table of ``network`` into ``output_dir``.
184
+
185
+ When ``tables`` is given (e.g. ``["NODE", "LINK"]``), only those tables
186
+ are exported; by default every table is exported.
187
+ """
188
+ out_dir = Path(output_dir)
189
+ out_dir.mkdir(parents=True, exist_ok=True)
190
+
191
+ written: List[str] = []
192
+ for table in _resolve_tables(network, tables):
193
+ if skip_empty and len(table.df) == 0:
194
+ continue
195
+ path = out_dir / f"{table.name}.csv"
196
+ export_table_csv(table.df, path, sep=sep, encoding=encoding)
197
+ written.append(str(path))
198
+ return written
199
+
200
+
201
+ def export_json(
202
+ network: "Network",
203
+ output_dir: str | os.PathLike[str],
204
+ encoding: str = "utf-8",
205
+ skip_empty: bool = True,
206
+ tables: Optional[Sequence[str]] = None,
207
+ ) -> List[str]:
208
+ """Write one JSON file (array of records) per table into ``output_dir``.
209
+
210
+ When ``tables`` is given, only those tables are exported; by default
211
+ every table is exported.
212
+ """
213
+ out_dir = Path(output_dir)
214
+ out_dir.mkdir(parents=True, exist_ok=True)
215
+
216
+ written: List[str] = []
217
+ for table in _resolve_tables(network, tables):
218
+ if skip_empty and len(table.df) == 0:
219
+ continue
220
+ path = out_dir / f"{table.name}.json"
221
+ export_table_json(table.df, path, encoding=encoding)
222
+ written.append(str(path))
223
+ return written
224
+
225
+
226
+ def export_geojson(
227
+ network: "Network",
228
+ output_dir: str | os.PathLike[str],
229
+ encoding: str = "utf-8",
230
+ skip_empty: bool = True,
231
+ tables: Optional[Sequence[str]] = None,
232
+ ) -> List[str]:
233
+ """Write a GeoJSON file for every requested table that has geometry.
234
+
235
+ When ``tables`` is given, only those tables are considered; by default
236
+ every table is considered. Tables without any geometry (no WKT column,
237
+ no ``XCOORD``/``YCOORD``) are skipped.
238
+ """
239
+ out_dir = Path(output_dir)
240
+ out_dir.mkdir(parents=True, exist_ok=True)
241
+
242
+ written: List[str] = []
243
+ for table in _resolve_tables(network, tables):
244
+ if skip_empty and len(table.df) == 0:
245
+ continue
246
+ path = out_dir / f"{table.name}.geojson"
247
+ if export_table_geojson(table.df, path, encoding=encoding):
248
+ written.append(str(path))
249
+ return written