Skip to content

Commit 9c83408

Browse files
Suggest a coupled tram's usual partner from tramwar.pl
tramwar.pl lists the set every 105Na, 105N2k-family and 123N car runs in. The second-car sheet now offers that car first, as "usually runs with it", ahead of the photo, the feed and the ±1 guess. That puts the right neighbour first, and finds the four sets that aren't neighbours at all (1381+1341, 1387+1383, 1390+1386, 2064+2037). Issue #84. Sets do change after a crash or a works visit, so it stays a suggestion like the others. tramwar.pl is a private site, so the sets are saved to data/tramwar-sets.json by hand (--tramwar), never fetched nightly. They ride in fleet.json as "pairs", which older builds ignore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ec642c8 commit 9c83408

8 files changed

Lines changed: 88 additions & 9 deletions

File tree

‎Tabor/Core/Fleet.swift‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ public struct VehicleModel: Codable, Hashable, Sendable, Identifiable {
8989
/// Cars that always run together, e.g. [[1000, 1001]]. In a model that isn't `coupled`,
9090
/// only these cars are.
9191
public let sets: [[Int]]
92+
/// The sets its cars usually run in, per tramwar.pl, e.g. [[1282, 1281]]. Unlike `sets`
93+
/// they can change (after a crash, a works visit), so they're only ever a suggestion.
94+
public let pairs: [[Int]]
9295
/// Its cars with no motor of their own, e.g. the vintage ND #1620: they run hitched behind a
9396
/// car of a model that `tows`, any one of them, so they're paired across models.
9497
public let trailers: [Int]
@@ -103,7 +106,7 @@ public struct VehicleModel: Codable, Hashable, Sendable, Identifiable {
103106
vintage: Bool = false, onTest: Bool = false, works: Bool = false, runs: String? = nil,
104107
trial: String? = nil, runsPl: String? = nil, trialPl: String? = nil, specs: ModelSpecs? = nil,
105108
variants: [SpecVariant] = [],
106-
liveries: [String: String]? = nil, coupled: Bool = false, sets: [[Int]] = [],
109+
liveries: [String: String]? = nil, coupled: Bool = false, sets: [[Int]] = [], pairs: [[Int]] = [],
107110
trailers: [Int] = [], tows: Bool = false, formerly: [String] = []) {
108111
self.id = id
109112
self.name = name
@@ -128,14 +131,15 @@ public struct VehicleModel: Codable, Hashable, Sendable, Identifiable {
128131
self.liveries = liveries
129132
self.coupled = coupled
130133
self.sets = sets
134+
self.pairs = pairs
131135
self.trailers = trailers
132136
self.tows = tows
133137
self.formerly = formerly
134138
}
135139

136140
private enum CodingKeys: String, CodingKey {
137141
case id, name, namePl, make, code, kind, operators, fleet, firstYear, lastYear, batches, vintage, onTest, works, runs, trial,
138-
runsPl, trialPl, specs, variants, liveries, coupled, sets, trailers, tows, formerly
142+
runsPl, trialPl, specs, variants, liveries, coupled, sets, pairs, trailers, tows, formerly
139143
}
140144

141145
public init(from decoder: Decoder) throws {
@@ -165,6 +169,7 @@ public struct VehicleModel: Codable, Hashable, Sendable, Identifiable {
165169
liveries = try? c.decodeIfPresent([String: String].self, forKey: .liveries)
166170
coupled = try c.decodeIfPresent(Bool.self, forKey: .coupled) ?? false
167171
sets = (try? c.decodeIfPresent([[Int]].self, forKey: .sets)) ?? []
172+
pairs = (try? c.decodeIfPresent([[Int]].self, forKey: .pairs)) ?? []
168173
trailers = (try? c.decodeIfPresent([Int].self, forKey: .trailers)) ?? []
169174
tows = (try? c.decodeIfPresent(Bool.self, forKey: .tows)) ?? false
170175
formerly = (try? c.decodeIfPresent([String].self, forKey: .formerly)) ?? []
@@ -189,7 +194,7 @@ public struct VehicleModel: Codable, Hashable, Sendable, Identifiable {
189194
lastYear: [lastYear, more.lastYear].compactMap { $0 }.max(),
190195
batches: batches + more.batches, vintage: vintage, onTest: onTest, works: works,
191196
runs: runs, trial: trial, runsPl: runsPl, trialPl: trialPl, specs: specs, variants: variants,
192-
liveries: liveries, coupled: coupled, sets: sets, trailers: trailers, tows: tows, formerly: formerly)
197+
liveries: liveries, coupled: coupled, sets: sets, pairs: pairs, trailers: trailers, tows: tows, formerly: formerly)
193198
}
194199

195200
public var yearsDisplay: String? {
@@ -246,6 +251,11 @@ public struct VehicleModel: Codable, Hashable, Sendable, Identifiable {
246251
sets.first { $0.contains(number) }?.first { $0 != number }
247252
}
248253

254+
/// The cars it usually runs with (one, or two in a three-car set), if tramwar.pl knows.
255+
public func usualPartners(of number: Int) -> [Int] {
256+
pairs.first { $0.contains(number) }?.filter { $0 != number } ?? []
257+
}
258+
249259
/// This vehicle's own specs.
250260
public func specs(of number: Int) -> ModelSpecs? {
251261
variants.first { $0.numbers.contains(number) }?.specs ?? specs

‎Tabor/Core/LiveFleet.swift‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,15 +283,15 @@ public enum CoupledSet {
283283

284284
public struct Suggestion: Sendable, Equatable {
285285
public enum Source: String, Sendable {
286-
case fixed, photo, feed, trailer
286+
case fixed, usual, photo, feed, trailer
287287
case neighbour = "±1"
288288
}
289289
public let number: Int
290290
public let source: Source
291291
}
292292

293293
/// Up to three numbers for the car coupled to `number`, likeliest first: its fixed partner,
294-
/// another car it can run with read in the photo, the feed's lead car, a motor car's
294+
/// the set tramwar.pl has it in, another car it can run with read in the photo, the feed's lead car, a motor car's
295295
/// trailers, then n−1 and n+1. Only cars it can really run with, never the caught number.
296296
public static func suggestions(for number: Int, model: VehicleModel, photoNumbers: [Int],
297297
nearby: [NearbyVehicle], catalog: FleetCatalog) -> [Suggestion] {
@@ -303,6 +303,7 @@ public enum CoupledSet {
303303
out.append(Suggestion(number: n, source: source))
304304
}
305305
add(model.fixedPartner(of: number), .fixed)
306+
model.usualPartners(of: number).forEach { add($0, .usual) }
306307
photoNumbers.forEach { add($0, .photo) }
307308
add(partner(of: number, model: model, nearby: nearby, catalog: catalog)?.number, .feed)
308309
if model.pullsTrailers(number) { catalog.trailers.forEach { add($0, .trailer) } }

‎Tabor/Resources/Localizable.xcstrings‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4617,6 +4617,17 @@
46174617
}
46184618
}
46194619
},
4620+
"USUALLY RUNS WITH IT": {
4621+
"comment": "Second-car suggestion: the set tramwar.pl lists it in",
4622+
"localizations": {
4623+
"pl": {
4624+
"stringUnit": {
4625+
"state": "translated",
4626+
"value": "ZWYKLE JEŹDZI Z NIM"
4627+
}
4628+
}
4629+
}
4630+
},
46204631
"OPEN": {
46214632
"localizations": {
46224633
"pl": {

‎Tabor/Resources/fleet.json‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎Tabor/Screens/PartnerSheet.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ struct PartnerSheet: View {
120120
private func why(_ source: CoupledSet.Suggestion.Source) -> String {
121121
switch source {
122122
case .fixed: String(localized: "ALWAYS RUNS WITH IT")
123+
case .usual: String(localized: "USUALLY RUNS WITH IT", comment: "Second-car suggestion: the set tramwar.pl lists it in")
123124
case .photo: String(localized: "ALSO READ IN YOUR PHOTO")
124125
case .feed: String(localized: "RUNNING RIGHT HERE NOW")
125126
case .trailer: String(localized: "VINTAGE TRAILER")

‎Tests/TaborCoreTests/TaborCoreTests.swift‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,15 +1553,31 @@ private let n13 = catalog.model(id: "tram-konstal-13n")!
15531553
}
15541554

15551555
@Test func partnerSuggestionsInOrder() {
1556+
// tramwar.pl has 1283 in 1284+1283.
15561557
let s = CoupledSet.suggestions(for: 1283, model: n105, photoNumbers: [1286], nearby: nearby([live(1282, .tram)]), catalog: catalog)
1557-
#expect(s.map(\.number) == [1286, 1282, 1284])
1558-
#expect(s.map(\.source) == [.photo, .feed, .neighbour])
1558+
#expect(s.map(\.number) == [1284, 1286, 1282])
1559+
#expect(s.map(\.source) == [.usual, .photo, .feed])
15591560
let fixed = CoupledSet.suggestions(for: 1252, model: n105Vintage, photoNumbers: [1000], nearby: nearby([live(1001, .tram)]), catalog: catalog)
15601561
#expect(fixed.map(\.number) == [1251, 1000, 1001])
15611562
#expect(fixed.first?.source == .fixed)
15621563
// The caught number and numbers not in the model are skipped, and three is the most.
15631564
let capped = CoupledSet.suggestions(for: 1283, model: n105, photoNumbers: [1283, 99_999, 1286, 1289, 1290], nearby: [], catalog: catalog)
1564-
#expect(capped.map(\.number) == [1286, 1289, 1290])
1565+
#expect(capped.map(\.number) == [1284, 1286, 1289])
1566+
}
1567+
1568+
@Test func usualPartnerNeedNotBeANeighbour() {
1569+
// Sets ±1 can't find (tramwar.pl): 1381+1341, 2064+2037.
1570+
#expect(n105.usualPartners(of: 1341) == [1381])
1571+
#expect(CoupledSet.suggestions(for: 1381, model: n105, photoNumbers: [], nearby: [], catalog: catalog).map(\.number)
1572+
== [1341, 1380])
1573+
let n2k = catalog.model(id: "tram-alstom-konstal-105n2k")!
1574+
let s = CoupledSet.suggestions(for: 2037, model: n2k, photoNumbers: [], nearby: [], catalog: catalog)
1575+
#expect(s.first == CoupledSet.Suggestion(number: 2064, source: .usual))
1576+
// Every running car of a coupled model is in a set; #2046 is parked.
1577+
for m in catalog.models where m.coupled {
1578+
let paired = Set(m.pairs.joined())
1579+
#expect(Set(m.numbers).subtracting(paired).subtracting([2046]).isEmpty, "\(m.id)")
1580+
}
15651581
}
15661582

15671583
@Test func partnerSuggestionsStayInTheModel() {

‎data/tramwar-sets.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"fetched": "2026-10-14", "source": ["https://tramwar.pl/tram105n.html", "https://tramwar.pl/tram105n2k.html"], "sets": [[1252, 1251], [1276, 1275], [1282, 1281], [1284, 1283], [1286, 1285], [1290, 1289], [1294, 1293], [1296, 1295], [1298, 1297], [1300, 1299], [1302, 1301], [1304, 1303], [1306, 1305], [1308, 1307], [1310, 1309], [1312, 1311], [1314, 1313], [1318, 1317], [1320, 1319], [1322, 1321], [1324, 1323], [1326, 1325], [1328, 1327], [1330, 1329], [1332, 1331], [1334, 1333], [1336, 1335], [1338, 1337], [1340, 1339], [1344, 1343], [1346, 1345], [1350, 1349], [1352, 1351], [1354, 1353], [1356, 1355], [1358, 1357], [1360, 1359], [1362, 1361], [1364, 1363], [1366, 1365], [1368, 1367], [1370, 1369], [1372, 1371], [1374, 1373], [1378, 1377], [1380, 1379], [1381, 1341], [1384, 1385], [1387, 1383], [1388, 1389], [1390, 1386], [1392, 1391], [1394, 1393], [1396, 1395], [1398, 1397], [1400, 1399], [1402, 1401], [1404, 1403], [1406, 1405], [1408, 1407], [1410, 1409], [1412, 1411], [1414, 1413], [1416, 1415], [1418, 1417], [1420, 1419], [1422, 1421], [1424, 1423], [1426, 1425], [1428, 1427], [1430, 1429], [1432, 1431], [1434, 1433], [1436, 1435], [1438, 1437], [1440, 1439], [1442, 1441], [1444, 1443], [1446, 1445], [1448, 1447], [1450, 1449], [1452, 1451], [1454, 1453], [1456, 1455], [1458, 1457], [1460, 1459], [1462, 1461], [1464, 1463], [1466, 1465], [1468, 1467], [1470, 1469], [2006, 2007], [2008, 2009], [2010, 2011], [2012, 2013], [2014, 2015], [2016, 2017], [2018, 2019], [2020, 2021], [2022, 2023], [2024, 2025], [2026, 2027], [2028, 2029], [2030, 2031], [2032, 2033], [2034, 2035], [2038, 2039], [2040, 2041], [2042, 2043], [2044, 2045], [2048, 2049], [2050, 2051], [2052, 2053], [2054, 2055], [2056, 2057], [2058, 2059], [2060, 2061], [2062, 2063], [2064, 2037], [2066, 2067], [2068, 2069], [2070, 2071], [2072, 2073], [2074, 2075], [2076, 2077], [2078, 2079], [2080, 2081], [2082, 2083], [2084, 2085], [2086, 2087], [2088, 2089], [2090, 2091], [2092, 2093], [2094, 2095], [2096, 2097], [2098, 2099], [2100, 2101], [2102, 2103], [2104, 2105], [2106, 2107], [2108, 2109], [2110, 2111], [2112, 2113], [2114, 2115], [2116, 2117], [2118, 2119], [2120, 2121], [2122, 2123], [2124, 2125], [2126, 2127], [2128, 2129], [2130, 2131], [2132, 2133], [2134, 2135], [2136, 2137], [2138, 2139], [2140, 2141], [2142, 2143], [2144, 2145], [2146, 2147], [2148, 2149], [2150, 2151], [2152, 2153], [2154, 2155], [2156, 2157], [2158, 2159], [2160, 2161], [2162, 2163], [2164, 2165]]}

‎scripts/fetch_fleet.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
Usage: python3 scripts/fetch_fleet.py # fetch the city's list + build
1414
python3 scripts/fetch_fleet.py --offline # rebuild from the saved files
1515
python3 scripts/fetch_fleet.py --scrape # re-scrape ZTM's database too (slow)
16+
python3 scripts/fetch_fleet.py --tramwar # refresh the coupled sets from tramwar.pl too
1617
python3 scripts/fetch_fleet.py --allow-drop # let model ids disappear (on purpose)
1718
python3 scripts/fetch_fleet.py --touch # restamp `fetched` even if nothing changed
1819
@@ -33,6 +34,7 @@
3334
ROOT = Path(__file__).resolve().parent.parent
3435
RAW = ROOT / "data" / "ztm-vehicles.json"
3536
CITY = ROOT / "data" / "ztm-pojazdy.json"
37+
SETS = ROOT / "data" / "tramwar-sets.json"
3638
OUT = ROOT / "Tabor" / "Resources" / "fleet.json"
3739
DANE = "https://dane.um.warszawa.pl/api/action/get_ztm_pojazdy"
3840
BASE = "https://www.ztm.waw.pl/baza-danych-pojazdow/"
@@ -182,6 +184,32 @@ def fetch_city():
182184
return city
183185

184186

187+
# ---------------------------------------------------------------- tramwar.pl's sets
188+
189+
# Which car each coupled tram runs with, per tramwar.pl (a private site, so fetched by hand with
190+
# --tramwar, never nightly). Sets change rarely, after a crash or a works visit, so the app only
191+
# suggests them (issue #84).
192+
TRAMWAR_SETS = ["https://tramwar.pl/tram105n.html", "https://tramwar.pl/tram105n2k.html"]
193+
194+
195+
def fetch_sets():
196+
sets = []
197+
for url in TRAMWAR_SETS:
198+
req = urllib.request.Request(url, headers=HEADERS)
199+
with urllib.request.urlopen(req, timeout=30) as r:
200+
# ISO 8859-2; only the digits matter here.
201+
text = re.sub(r"<[^>]+>", " ", r.read().decode("latin-1"))
202+
# "1252+1251", or "1392"+1391" with quotes marking the second car to carry a number.
203+
found = [[int(n) for n in re.findall(r"\d{3,4}", m)]
204+
for m in re.findall(r"\b\d{3,4}[\"']*(?:\+\d{3,4}[\"']*)+", text)]
205+
if len(found) < 15:
206+
raise RuntimeError(f"only {len(found)} sets on {url}: has the page changed?")
207+
sets += found
208+
SETS.write_text(json.dumps({"fetched": date.today().isoformat(), "source": TRAMWAR_SETS,
209+
"sets": sorted(sets)}))
210+
print(f"tramwar: {len(sets)} sets")
211+
212+
185213
def with_city(vehicles, city, report=True):
186214
"""The scrape plus the vehicles only the city lists. A new number joins the model most
187215
numbers of its type (idMarki) already belong to; a type nothing known belongs to is
@@ -823,6 +851,15 @@ def build(vehicles, city=None):
823851
m["trailers"] = trailers
824852
if m["id"] in TOWING:
825853
m["tows"] = True
854+
# tramwar.pl's sets, on the coupled model that has every car of one. A set across models
855+
# (or with a car we don't have) is printed and left out. Emitted as "pairs", which older
856+
# app versions ignore.
857+
coupled = [m for m in models if m["id"] in COUPLED]
858+
for cars in json.loads(SETS.read_text())["sets"] if SETS.exists() else []:
859+
if m := next((m for m in coupled if set(cars) <= numbers[m["id"]]), None):
860+
m.setdefault("pairs", []).append(cars)
861+
elif any(set(cars) & numbers[m["id"]] for m in coupled):
862+
print(f" - tramwar set {'+'.join(map(str, cars))} isn't within one model, left out")
826863
assert sum(len(m.get("trailers", [])) for m in models) == len(TRAILERS), "a TRAILERS number isn't in the data"
827864
assert not TOWING - numbers.keys(), f"TOWING ids not in the data any more: {TOWING - numbers.keys()}"
828865
missing = COUPLED - numbers.keys()
@@ -901,6 +938,8 @@ def build(vehicles, city=None):
901938
if __name__ == "__main__":
902939
if "--scrape" in sys.argv:
903940
scrape()
941+
if "--tramwar" in sys.argv:
942+
fetch_sets()
904943
if "--offline" not in sys.argv:
905944
fetch_city()
906945
city = load_city()

0 commit comments

Comments
 (0)