tradedangerous 12.7.6__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.
- py.typed +1 -0
- trade.py +49 -0
- tradedangerous/__init__.py +43 -0
- tradedangerous/cache.py +1381 -0
- tradedangerous/cli.py +136 -0
- tradedangerous/commands/TEMPLATE.py +74 -0
- tradedangerous/commands/__init__.py +244 -0
- tradedangerous/commands/buildcache_cmd.py +102 -0
- tradedangerous/commands/buy_cmd.py +427 -0
- tradedangerous/commands/commandenv.py +372 -0
- tradedangerous/commands/exceptions.py +94 -0
- tradedangerous/commands/export_cmd.py +150 -0
- tradedangerous/commands/import_cmd.py +222 -0
- tradedangerous/commands/local_cmd.py +243 -0
- tradedangerous/commands/market_cmd.py +207 -0
- tradedangerous/commands/nav_cmd.py +252 -0
- tradedangerous/commands/olddata_cmd.py +270 -0
- tradedangerous/commands/parsing.py +221 -0
- tradedangerous/commands/rares_cmd.py +298 -0
- tradedangerous/commands/run_cmd.py +1521 -0
- tradedangerous/commands/sell_cmd.py +262 -0
- tradedangerous/commands/shipvendor_cmd.py +60 -0
- tradedangerous/commands/station_cmd.py +68 -0
- tradedangerous/commands/trade_cmd.py +181 -0
- tradedangerous/commands/update_cmd.py +67 -0
- tradedangerous/corrections.py +55 -0
- tradedangerous/csvexport.py +234 -0
- tradedangerous/db/__init__.py +27 -0
- tradedangerous/db/adapter.py +192 -0
- tradedangerous/db/config.py +107 -0
- tradedangerous/db/engine.py +259 -0
- tradedangerous/db/lifecycle.py +332 -0
- tradedangerous/db/locks.py +208 -0
- tradedangerous/db/orm_models.py +500 -0
- tradedangerous/db/paths.py +113 -0
- tradedangerous/db/utils.py +661 -0
- tradedangerous/edscupdate.py +565 -0
- tradedangerous/edsmupdate.py +474 -0
- tradedangerous/formatting.py +210 -0
- tradedangerous/fs.py +156 -0
- tradedangerous/gui.py +1146 -0
- tradedangerous/mapping.py +133 -0
- tradedangerous/mfd/__init__.py +103 -0
- tradedangerous/mfd/saitek/__init__.py +3 -0
- tradedangerous/mfd/saitek/directoutput.py +678 -0
- tradedangerous/mfd/saitek/x52pro.py +195 -0
- tradedangerous/misc/checkpricebounds.py +287 -0
- tradedangerous/misc/clipboard.py +49 -0
- tradedangerous/misc/coord64.py +83 -0
- tradedangerous/misc/csvdialect.py +57 -0
- tradedangerous/misc/derp-sentinel.py +35 -0
- tradedangerous/misc/diff-system-csvs.py +159 -0
- tradedangerous/misc/eddb.py +81 -0
- tradedangerous/misc/eddn.py +349 -0
- tradedangerous/misc/edsc.py +437 -0
- tradedangerous/misc/edsm.py +121 -0
- tradedangerous/misc/importeddbstats.py +54 -0
- tradedangerous/misc/prices-json-exp.py +179 -0
- tradedangerous/misc/progress.py +194 -0
- tradedangerous/plugins/__init__.py +249 -0
- tradedangerous/plugins/edcd_plug.py +371 -0
- tradedangerous/plugins/eddblink_plug.py +861 -0
- tradedangerous/plugins/edmc_batch_plug.py +133 -0
- tradedangerous/plugins/spansh_plug.py +2647 -0
- tradedangerous/prices.py +211 -0
- tradedangerous/submit-distances.py +422 -0
- tradedangerous/templates/Added.csv +37 -0
- tradedangerous/templates/Category.csv +17 -0
- tradedangerous/templates/RareItem.csv +143 -0
- tradedangerous/templates/TradeDangerous.sql +338 -0
- tradedangerous/tools.py +40 -0
- tradedangerous/tradecalc.py +1302 -0
- tradedangerous/tradedb.py +2320 -0
- tradedangerous/tradeenv.py +313 -0
- tradedangerous/tradeenv.pyi +109 -0
- tradedangerous/tradeexcept.py +131 -0
- tradedangerous/tradeorm.py +183 -0
- tradedangerous/transfers.py +192 -0
- tradedangerous/utils.py +243 -0
- tradedangerous/version.py +16 -0
- tradedangerous-12.7.6.dist-info/METADATA +106 -0
- tradedangerous-12.7.6.dist-info/RECORD +87 -0
- tradedangerous-12.7.6.dist-info/WHEEL +5 -0
- tradedangerous-12.7.6.dist-info/entry_points.txt +3 -0
- tradedangerous-12.7.6.dist-info/licenses/LICENSE +373 -0
- tradedangerous-12.7.6.dist-info/top_level.txt +2 -0
- tradegui.py +24 -0
|
@@ -0,0 +1,2320 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (C) Oliver 'kfsone' Smith 2014 <oliver@kfs.org>:
|
|
3
|
+
Copyright (C) Bernd 'Gazelle' Gollesch 2016, 2017
|
|
4
|
+
Copyright (C) Stefan 'Tromador' Morrell 2025
|
|
5
|
+
Copyright (C) Jonathan 'eyeonus' Jones 2018 - 2025
|
|
6
|
+
|
|
7
|
+
You are free to use, redistribute, or even print and eat a copy of
|
|
8
|
+
this software so long as you include this copyright notice.
|
|
9
|
+
|
|
10
|
+
I guarantee there is at least one bug neither of us knew about. -- Oliver
|
|
11
|
+
--------------------------------------------------------------------
|
|
12
|
+
TradeDangerous :: Modules :: Database Module
|
|
13
|
+
|
|
14
|
+
Provides the primary classes used within TradeDangerous:
|
|
15
|
+
|
|
16
|
+
TradeDB, System, Station, Ship, Item, and Trade.
|
|
17
|
+
|
|
18
|
+
These classes are primarily for describing the database.
|
|
19
|
+
|
|
20
|
+
Simplistic use might be:
|
|
21
|
+
|
|
22
|
+
import tradedb
|
|
23
|
+
|
|
24
|
+
# Create an instance: You can specify a debug level as a
|
|
25
|
+
# parameter, for more advanced configuration, see the
|
|
26
|
+
# tradeenv.TradeEnv() class.
|
|
27
|
+
tdb = tradedb.TradeDB()
|
|
28
|
+
|
|
29
|
+
# look up a System by name
|
|
30
|
+
sol = tdb.lookupSystem("SOL")
|
|
31
|
+
ibootis = tdb.lookupSystem("i BootiS")
|
|
32
|
+
ibootis = tdb.lookupSystem("ibootis")
|
|
33
|
+
|
|
34
|
+
# look up a Station by name
|
|
35
|
+
abe = tdb.lookupStation("Abraham Lincoln")
|
|
36
|
+
abe = tdb.lookupStation("Abraham Lincoln", sol)
|
|
37
|
+
abe = tdb.lookupStation("hamlinc")
|
|
38
|
+
|
|
39
|
+
# look up something that could be a system or station,
|
|
40
|
+
# where 'place' syntax can be:
|
|
41
|
+
# SYS, STN, SYS/STN, @SYS, /STN or @SYS/STN
|
|
42
|
+
abe = tdb.lookupPlace("Abraham Lincoln")
|
|
43
|
+
abe = tdb.lookupPlace("HamLinc")
|
|
44
|
+
abe = tdb.lookupPlace("@SOL/HamLinc")
|
|
45
|
+
abe = tdb.lookupPlace("so/haml")
|
|
46
|
+
abe = tdb.lookupPlace("sol/abraham lincoln")
|
|
47
|
+
abe = tdb.lookupPlace("@sol/abrahamlincoln")
|
|
48
|
+
james = tdb.lookupPlace("shin/jamesmem")
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
######################################################################
|
|
52
|
+
# Imports
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
from collections import namedtuple
|
|
56
|
+
from functools import lru_cache
|
|
57
|
+
from math import floor as math_floor, sqrt as math_sqrt
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
from typing import NamedTuple
|
|
60
|
+
import heapq
|
|
61
|
+
import itertools
|
|
62
|
+
import locale
|
|
63
|
+
import os
|
|
64
|
+
import re
|
|
65
|
+
import sys
|
|
66
|
+
import time
|
|
67
|
+
import typing
|
|
68
|
+
|
|
69
|
+
from .tradeenv import TradeEnv
|
|
70
|
+
from .tradeexcept import TradeException, AmbiguityError, SystemNotStationError
|
|
71
|
+
from . import cache, fs
|
|
72
|
+
|
|
73
|
+
from sqlalchemy import func, select, text
|
|
74
|
+
from sqlalchemy.exc import NoResultFound
|
|
75
|
+
from .db import make_engine_from_config, get_session_factory # type: ignore
|
|
76
|
+
from .db.lifecycle import ensure_fresh_db # type: ignore
|
|
77
|
+
from .db.utils import age_in_days # type: ignore
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------
|
|
80
|
+
# SQLAlchemy ORM imports (aliased to avoid clashing with legacy wrappers).
|
|
81
|
+
# These map to the actual database tables via SQLAlchemy and are used
|
|
82
|
+
# internally in loaders/writers to replace raw sqlite3 queries.
|
|
83
|
+
#
|
|
84
|
+
# NOTE: We still instantiate and use legacy wrapper classes defined in
|
|
85
|
+
# this file (System, Station, Item, etc.) to maintain API compatibility
|
|
86
|
+
# across the rest of the codebase (Pass 1 migration).
|
|
87
|
+
#
|
|
88
|
+
# In a possible future cleanup (Pass 2), the wrappers may be removed
|
|
89
|
+
# entirely, and code updated to use ORM models directly.
|
|
90
|
+
# --------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
from .db.orm_models import ( # noqa: F401 # pylint: disable=unused-import
|
|
93
|
+
Added as SA_Added,
|
|
94
|
+
System as SA_System,
|
|
95
|
+
Station as SA_Station,
|
|
96
|
+
Item as SA_Item,
|
|
97
|
+
Category as SA_Category,
|
|
98
|
+
StationItem as SA_StationItem,
|
|
99
|
+
RareItem as SA_RareItem,
|
|
100
|
+
Ship as SA_Ship,
|
|
101
|
+
ShipVendor as SA_ShipVendor,
|
|
102
|
+
Upgrade as SA_Upgrade,
|
|
103
|
+
UpgradeVendor as SA_UpgradeVendor,
|
|
104
|
+
ExportControl as SA_ExportControl,
|
|
105
|
+
StationItemStaging as SA_StationItemStaging,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
locale.setlocale(locale.LC_ALL, '')
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if typing.TYPE_CHECKING:
|
|
113
|
+
from collections.abc import Generator
|
|
114
|
+
from typing import Any, Optional
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
######################################################################
|
|
118
|
+
# Classes
|
|
119
|
+
|
|
120
|
+
######################################################################
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def make_stellar_grid_key(x: float, y: float, z: float) -> tuple[int, int, int]:
|
|
124
|
+
"""
|
|
125
|
+
The Stellar Grid is a map of systems based on their Stellar
|
|
126
|
+
co-ordinates rounded down to 32lys. This makes it much easier
|
|
127
|
+
to find stars within rectangular volumes.
|
|
128
|
+
"""
|
|
129
|
+
# Originally we used int(x) >> 5, but this caused bunching around negatives.
|
|
130
|
+
# int(0.1) == 0 but so does int(-0.1). We should probably create the
|
|
131
|
+
# per-system stellar grid keys once, in the database, and store those
|
|
132
|
+
# against the system.
|
|
133
|
+
return math_floor(x) >> 5, math_floor(y) >> 5, math_floor(z) >> 5
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class System:
|
|
137
|
+
"""
|
|
138
|
+
Describes a star system which may contain one or more Station objects.
|
|
139
|
+
|
|
140
|
+
Caution: Do not use _rangeCache directly, use TradeDB.genSystemsInRange.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
__slots__ = (
|
|
144
|
+
'ID',
|
|
145
|
+
'dbname', 'posX', 'posY', 'posZ', 'pos', 'stations',
|
|
146
|
+
'addedID',
|
|
147
|
+
'_rangeCache'
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
class RangeCache:
|
|
151
|
+
"""
|
|
152
|
+
Lazily populated cache of neighboring systems.
|
|
153
|
+
"""
|
|
154
|
+
def __init__(self):
|
|
155
|
+
self.systems = []
|
|
156
|
+
self.probed_ly = 0.
|
|
157
|
+
|
|
158
|
+
def __init__(self, ID: int, dbname: str, posX: float, posY: float, posZ: float, addedID: int | None) -> None:
|
|
159
|
+
self.ID = ID
|
|
160
|
+
self.dbname = dbname
|
|
161
|
+
self.posX, self.posY, self.posZ = posX, posY, posZ
|
|
162
|
+
self.addedID = addedID or 0
|
|
163
|
+
self.stations: list['Station'] = []
|
|
164
|
+
self._rangeCache = None
|
|
165
|
+
|
|
166
|
+
def __repr__(self) -> str:
|
|
167
|
+
return f"<System ID={self.ID} dbname='{self.dbname}' pos=({self.posX},{self.posY},{self.posZ})>"
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def system(self) -> 'System':
|
|
171
|
+
""" Returns self for compatibility with the undefined 'Positional' interface. """
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
def distanceTo(self, other: 'System') -> float:
|
|
175
|
+
"""
|
|
176
|
+
Returns the distance (in ly) between two systems.
|
|
177
|
+
|
|
178
|
+
NOTE: If you are primarily testing/comparing
|
|
179
|
+
distances, consider using "distToSq" for the test.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Distance in light years.
|
|
183
|
+
|
|
184
|
+
Example:
|
|
185
|
+
print("{} -> {}: {} ly".format(
|
|
186
|
+
lhs.name(), rhs.name(),
|
|
187
|
+
lhs.distanceTo(rhs),
|
|
188
|
+
))
|
|
189
|
+
"""
|
|
190
|
+
dx, dy, dz = self.posX - other.posX, self.posY - other.posY, self.posZ - other.posZ
|
|
191
|
+
return math_sqrt(dx * dx + dy * dy + dz * dz)
|
|
192
|
+
|
|
193
|
+
def getStation(self, name: str) -> 'Optional[Station]':
|
|
194
|
+
"""
|
|
195
|
+
Quick case-insensitive lookup of a station name within the
|
|
196
|
+
stations in this system.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Station() object if a match is found,
|
|
200
|
+
otherwise None.
|
|
201
|
+
"""
|
|
202
|
+
name = name.upper()
|
|
203
|
+
for station in self.stations:
|
|
204
|
+
if station.name == name:
|
|
205
|
+
return station
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
def name(self, detail: int = 0) -> str: # pylint: disable=unused-argument
|
|
209
|
+
""" Returns the display name for this System."""
|
|
210
|
+
return self.dbname
|
|
211
|
+
|
|
212
|
+
def text(self) -> str:
|
|
213
|
+
return self.dbname
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
######################################################################
|
|
217
|
+
|
|
218
|
+
class Destination(NamedTuple):
|
|
219
|
+
system: 'System'
|
|
220
|
+
station: 'Station'
|
|
221
|
+
via: list['System']
|
|
222
|
+
distLy: float
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class DestinationNode(NamedTuple):
|
|
226
|
+
system: 'System'
|
|
227
|
+
via: list['System']
|
|
228
|
+
distLy: float
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class Station:
|
|
232
|
+
"""
|
|
233
|
+
Describes a station (trading or otherwise) in a system.
|
|
234
|
+
|
|
235
|
+
For obtaining trade information for a given station see one of:
|
|
236
|
+
TradeCalc.getTrades (fast and cheap)
|
|
237
|
+
"""
|
|
238
|
+
__slots__ = (
|
|
239
|
+
'ID', 'system', 'dbname',
|
|
240
|
+
'lsFromStar', 'market', 'blackMarket', 'shipyard', 'maxPadSize',
|
|
241
|
+
'outfitting', 'rearm', 'refuel', 'repair', 'planetary','fleet',
|
|
242
|
+
'odyssey', 'itemCount', 'dataAge',
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def __init__(
|
|
246
|
+
self, ID: int, system: 'System', dbname: str,
|
|
247
|
+
lsFromStar: float, market: str, blackMarket: str, shipyard: str, maxPadSize: str,
|
|
248
|
+
outfitting: str, rearm: str, refuel: str, repair: str, planetary: str, fleet: str, odyssey: str,
|
|
249
|
+
itemCount: int = 0, dataAge: float | int | None = None,
|
|
250
|
+
):
|
|
251
|
+
self.ID, self.system, self.dbname = ID, system, dbname # type: ignore
|
|
252
|
+
self.lsFromStar = int(lsFromStar)
|
|
253
|
+
self.market = market if itemCount == 0 else 'Y'
|
|
254
|
+
self.blackMarket = blackMarket
|
|
255
|
+
self.shipyard = shipyard
|
|
256
|
+
self.maxPadSize = maxPadSize
|
|
257
|
+
self.outfitting = outfitting
|
|
258
|
+
self.rearm = rearm
|
|
259
|
+
self.refuel = refuel
|
|
260
|
+
self.repair = repair
|
|
261
|
+
self.planetary = planetary
|
|
262
|
+
self.fleet = fleet
|
|
263
|
+
self.odyssey = odyssey
|
|
264
|
+
self.itemCount = itemCount
|
|
265
|
+
self.dataAge = dataAge
|
|
266
|
+
system.stations += [self]
|
|
267
|
+
|
|
268
|
+
def __repr__(self) -> str:
|
|
269
|
+
return f"<Station ID={self.ID} dbname='{self.dbname}' system_id={self.system.ID} system='{self.system.dbname}'>"
|
|
270
|
+
|
|
271
|
+
def name(self, detail: int = 0) -> str: # pylint: disable=unused-argument
|
|
272
|
+
return f"{self.system.dbname}/{self.dbname}"
|
|
273
|
+
|
|
274
|
+
def checkPadSize(self, maxPadSize: str) -> bool:
|
|
275
|
+
"""
|
|
276
|
+
Tests if the Station's max pad size matches one of the
|
|
277
|
+
values in 'maxPadSize'.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
maxPadSize
|
|
281
|
+
A string of one or more max pad size values that
|
|
282
|
+
you want to match against.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
True
|
|
286
|
+
If self.maxPadSize is None or empty, or matches a
|
|
287
|
+
member of maxPadSize
|
|
288
|
+
False
|
|
289
|
+
If maxPadSize was not empty but self.maxPadSize
|
|
290
|
+
did not match it.
|
|
291
|
+
|
|
292
|
+
Examples:
|
|
293
|
+
# Require a medium max pad size - not small or large
|
|
294
|
+
station.checkPadSize("M")
|
|
295
|
+
# Require medium or unknown
|
|
296
|
+
station.checkPadSize("M?")
|
|
297
|
+
# Require small, large or unknown
|
|
298
|
+
station.checkPadSize("SL?")
|
|
299
|
+
"""
|
|
300
|
+
return (not maxPadSize or self.maxPadSize in maxPadSize)
|
|
301
|
+
|
|
302
|
+
def checkPlanetary(self, planetary: str) -> bool:
|
|
303
|
+
"""
|
|
304
|
+
Tests if the Station's planetary matches one of the
|
|
305
|
+
values in 'planetary'.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
planetary
|
|
309
|
+
A string of one or more planetary values that
|
|
310
|
+
you want to match against.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
True
|
|
314
|
+
If self.planetary is None or empty, or matches a
|
|
315
|
+
member of planetary
|
|
316
|
+
False
|
|
317
|
+
If planetary was not empty but self.planetary
|
|
318
|
+
did not match it.
|
|
319
|
+
|
|
320
|
+
Examples:
|
|
321
|
+
# Require a planetary station
|
|
322
|
+
station.checkPlanetary("Y")
|
|
323
|
+
# Require planetary or unknown
|
|
324
|
+
station.checkPlanetary("Y?")
|
|
325
|
+
# Require no planetary station
|
|
326
|
+
station.checkPlanetary("N")
|
|
327
|
+
"""
|
|
328
|
+
return (not planetary or self.planetary in planetary)
|
|
329
|
+
|
|
330
|
+
def checkFleet(self, fleet: str) -> bool:
|
|
331
|
+
"""
|
|
332
|
+
Same as checkPlanetary, but for fleet carriers.
|
|
333
|
+
"""
|
|
334
|
+
return (not fleet or self.fleet in fleet)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def checkOdyssey(self, odyssey: str) -> bool:
|
|
338
|
+
"""
|
|
339
|
+
Same as checkPlanetary, but for Odyssey.
|
|
340
|
+
"""
|
|
341
|
+
return (not odyssey or self.odyssey in odyssey)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def distFromStar(self, addSuffix: bool = False) -> str:
|
|
345
|
+
"""
|
|
346
|
+
Returns a textual description of the distance from this
|
|
347
|
+
Station to the parent star.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
addSuffix[=False]:
|
|
351
|
+
Always add a unit suffix (ls, Kls, ly)
|
|
352
|
+
"""
|
|
353
|
+
ls = self.lsFromStar
|
|
354
|
+
if not ls:
|
|
355
|
+
return "Unk" if addSuffix else "?"
|
|
356
|
+
|
|
357
|
+
suffix = "ls" if addSuffix else ""
|
|
358
|
+
|
|
359
|
+
if ls < 1000:
|
|
360
|
+
return f"{ls:n}{suffix}"
|
|
361
|
+
if ls < 10000:
|
|
362
|
+
return f"{ls / 1000:.2f}K{suffix}"
|
|
363
|
+
if ls < 1000000:
|
|
364
|
+
return f"{int(ls / 1000):n}K{suffix}"
|
|
365
|
+
return f'{ls / (365*24*60*60):.2f}ly'
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def isTrading(self) -> bool:
|
|
369
|
+
"""
|
|
370
|
+
True if the station is thought to be trading.
|
|
371
|
+
|
|
372
|
+
A station is considered 'trading' if it has an item count > 0 or
|
|
373
|
+
if it's "market" column is flagged 'Y'.
|
|
374
|
+
"""
|
|
375
|
+
return (self.itemCount > 0 or self.market == 'Y')
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def itemDataAgeStr(self):
|
|
379
|
+
""" Returns the age in days of item data if present, else "-". """
|
|
380
|
+
if self.itemCount and self.dataAge:
|
|
381
|
+
return f"{self.dataAge:7.2f}"
|
|
382
|
+
return "-"
|
|
383
|
+
|
|
384
|
+
def text(self) -> str:
|
|
385
|
+
return f"{self.system.dbname}/{self.dbname}"
|
|
386
|
+
|
|
387
|
+
######################################################################
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class Ship(NamedTuple):
|
|
391
|
+
"""
|
|
392
|
+
Ship description.
|
|
393
|
+
|
|
394
|
+
Attributes:
|
|
395
|
+
ID -- FDevID as provided by the companion API.
|
|
396
|
+
dbname -- The name as present in the database
|
|
397
|
+
cost -- How many credits to buy
|
|
398
|
+
stations -- List of Stations ship is sold at.
|
|
399
|
+
"""
|
|
400
|
+
ID: int
|
|
401
|
+
dbname: str
|
|
402
|
+
cost: int
|
|
403
|
+
stations: list[Station]
|
|
404
|
+
|
|
405
|
+
def name(self, _detail: int = 0) -> str:
|
|
406
|
+
return self.dbname
|
|
407
|
+
|
|
408
|
+
######################################################################
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class Category(NamedTuple):
|
|
412
|
+
"""
|
|
413
|
+
Item Category
|
|
414
|
+
|
|
415
|
+
Items are organized into categories (Food, Drugs, Metals, etc).
|
|
416
|
+
Category object describes a category's ID, name and list of items.
|
|
417
|
+
|
|
418
|
+
Attributes:
|
|
419
|
+
ID
|
|
420
|
+
The database ID
|
|
421
|
+
dbname
|
|
422
|
+
The name as present in the database.
|
|
423
|
+
items
|
|
424
|
+
List of Item objects within this category.
|
|
425
|
+
|
|
426
|
+
Member Functions:
|
|
427
|
+
name()
|
|
428
|
+
Returns the display name for this Category.
|
|
429
|
+
"""
|
|
430
|
+
ID: int
|
|
431
|
+
dbname: str
|
|
432
|
+
items: list['Item']
|
|
433
|
+
|
|
434
|
+
def name(self, _detail: int = 0) -> str:
|
|
435
|
+
return self.dbname.upper()
|
|
436
|
+
|
|
437
|
+
######################################################################
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class Item:
|
|
441
|
+
"""
|
|
442
|
+
A product that can be bought/sold in the game.
|
|
443
|
+
|
|
444
|
+
Attributes:
|
|
445
|
+
ID -- Database ID.
|
|
446
|
+
dbname -- Name as it appears in-game and in the DB.
|
|
447
|
+
category -- Reference to the category.
|
|
448
|
+
fullname -- Combined category/dbname for lookups.
|
|
449
|
+
avgPrice -- Galactic average as shown in game.
|
|
450
|
+
fdevID -- FDevID as provided by the companion API.
|
|
451
|
+
"""
|
|
452
|
+
__slots__ = ('ID', 'dbname', 'category', 'fullname', 'avgPrice', 'fdevID')
|
|
453
|
+
|
|
454
|
+
def __init__(self, ID: int, dbname: str, category: 'Category', fullname: str, avgPrice: int | None = None, fdevID: int | None = None) -> None:
|
|
455
|
+
self.ID = ID
|
|
456
|
+
self.dbname = dbname
|
|
457
|
+
self.category = category
|
|
458
|
+
self.fullname = fullname
|
|
459
|
+
self.avgPrice = avgPrice
|
|
460
|
+
self.fdevID = fdevID
|
|
461
|
+
|
|
462
|
+
def name(self, detail: int = 0):
|
|
463
|
+
return self.fullname if detail > 0 else self.dbname
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
######################################################################
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class Trade(NamedTuple):
|
|
470
|
+
"""
|
|
471
|
+
Describes what it would cost and how much you would gain
|
|
472
|
+
when selling an item between two specific stations.
|
|
473
|
+
"""
|
|
474
|
+
item: Item
|
|
475
|
+
costCr: int
|
|
476
|
+
gainCr: int
|
|
477
|
+
supply: int
|
|
478
|
+
supplyLevel: int
|
|
479
|
+
demand: int
|
|
480
|
+
demandLevel: int
|
|
481
|
+
srcAge: float | None
|
|
482
|
+
dstAge: float | None
|
|
483
|
+
|
|
484
|
+
def name(self, detail: int = 0) -> str:
|
|
485
|
+
return self.item.name(detail=detail)
|
|
486
|
+
|
|
487
|
+
######################################################################
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class TradeDB:
|
|
491
|
+
"""
|
|
492
|
+
Encapsulation for the database layer.
|
|
493
|
+
|
|
494
|
+
Attributes:
|
|
495
|
+
dataPath
|
|
496
|
+
Path() to the data directory
|
|
497
|
+
dbPath
|
|
498
|
+
Path() of the .db location
|
|
499
|
+
tradingCount
|
|
500
|
+
Number of "profitable trade" items processed
|
|
501
|
+
tradingStationCount
|
|
502
|
+
Number of stations trade data has been loaded for
|
|
503
|
+
tdenv
|
|
504
|
+
The TradeEnv associated with this TradeDB
|
|
505
|
+
sqlPath
|
|
506
|
+
Path() of the .sql file
|
|
507
|
+
pricesPath
|
|
508
|
+
Path() of the .prices file
|
|
509
|
+
importTables
|
|
510
|
+
List of the .csv files
|
|
511
|
+
|
|
512
|
+
Static methods:
|
|
513
|
+
calculateDistance2(lx, ly, lz, rx, ry, rz)
|
|
514
|
+
Returns the square of the distance in ly between two points.
|
|
515
|
+
|
|
516
|
+
calculateDistance(lx, ly, lz, rx, ry, rz)
|
|
517
|
+
Returns the distance in ly between two points.
|
|
518
|
+
|
|
519
|
+
listSearch(...)
|
|
520
|
+
Performs partial and ambiguity matching of a word from a list
|
|
521
|
+
of potential values.
|
|
522
|
+
|
|
523
|
+
normalizedStr(text)
|
|
524
|
+
Case and punctuation normalizes a string to make it easier
|
|
525
|
+
to find approximate matches.
|
|
526
|
+
"""
|
|
527
|
+
|
|
528
|
+
# Translation map for normalizing strings
|
|
529
|
+
normalizeTrans = str.maketrans(
|
|
530
|
+
'abcdefghijklmnopqrstuvwxyz',
|
|
531
|
+
'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
|
532
|
+
'[]()*+-.,{}:'
|
|
533
|
+
)
|
|
534
|
+
trimTrans = str.maketrans('', '', ' \'')
|
|
535
|
+
|
|
536
|
+
# The DB cache
|
|
537
|
+
defaultDB: str = 'TradeDangerous.db'
|
|
538
|
+
# File containing SQL to build the DB cache from
|
|
539
|
+
defaultSQL: str = 'TradeDangerous.sql'
|
|
540
|
+
persistFile: str = 'TradeDB.pj' # discontinued "pickle" snapshot
|
|
541
|
+
# # File containing text description of prices
|
|
542
|
+
# defaultPrices = 'TradeDangerous.prices'
|
|
543
|
+
# array containing standard tables, csvfilename and tablename
|
|
544
|
+
# WARNING: order is important because of dependencies!
|
|
545
|
+
defaultTables = (
|
|
546
|
+
('Added.csv', 'Added'),
|
|
547
|
+
('System.csv', 'System'),
|
|
548
|
+
('Station.csv', 'Station'),
|
|
549
|
+
('Ship.csv', 'Ship'),
|
|
550
|
+
('ShipVendor.csv', 'ShipVendor'),
|
|
551
|
+
('Upgrade.csv', 'Upgrade'),
|
|
552
|
+
('UpgradeVendor.csv', 'UpgradeVendor'),
|
|
553
|
+
('Category.csv', 'Category'),
|
|
554
|
+
('Item.csv', 'Item'),
|
|
555
|
+
('StationItem.csv', 'StationItem'),
|
|
556
|
+
('RareItem.csv', 'RareItem'),
|
|
557
|
+
('FDevShipyard.csv', 'FDevShipyard'),
|
|
558
|
+
('FDevOutfitting.csv', 'FDevOutfitting'),
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# Translation matrixes for attributes -> common presentation
|
|
562
|
+
marketStates = planetStates = fleetStates = odysseyStates = {'?': '?', 'Y': 'Yes', 'N': 'No'}
|
|
563
|
+
marketStatesExt = planetStatesExt = fleetStatesExt = odysseyStatesExt = {'?': 'Unk', 'Y': 'Yes', 'N': 'No'}
|
|
564
|
+
padSizes = {'?': '?', 'S': 'Sml', 'M': 'Med', 'L': 'Lrg'}
|
|
565
|
+
padSizesExt = {'?': 'Unk', 'S': 'Sml', 'M': 'Med', 'L': 'Lrg'}
|
|
566
|
+
|
|
567
|
+
def __init__(
|
|
568
|
+
self,
|
|
569
|
+
tdenv: TradeEnv | None = None,
|
|
570
|
+
load: bool = True,
|
|
571
|
+
debug: int | None = None,
|
|
572
|
+
) -> None:
|
|
573
|
+
# --- SQLAlchemy engine/session (replaces sqlite3.Connection) ---
|
|
574
|
+
self.engine = None
|
|
575
|
+
self.Session = None
|
|
576
|
+
self.tradingCount = None
|
|
577
|
+
|
|
578
|
+
# Environment
|
|
579
|
+
tdenv = tdenv or TradeEnv(debug=(debug or 0))
|
|
580
|
+
self.tdenv = tdenv
|
|
581
|
+
|
|
582
|
+
# --- Path setup (unchanged) ---
|
|
583
|
+
self.templatePath = Path(tdenv.templateDir).resolve()
|
|
584
|
+
self.dataPath = dataPath = fs.ensurefolder(tdenv.dataDir)
|
|
585
|
+
self.csvPath = fs.ensurefolder(tdenv.csvDir)
|
|
586
|
+
|
|
587
|
+
# Template bootstrap files: copy ONLY if missing (never overwrite on pip upgrade).
|
|
588
|
+
fs.copy_if_missing(self.templatePath / "Added.csv", self.csvPath / "Added.csv")
|
|
589
|
+
fs.copy_if_missing(self.templatePath / "RareItem.csv", self.csvPath / "RareItem.csv")
|
|
590
|
+
fs.copy_if_missing(self.templatePath / "Category.csv", self.csvPath / "Category.csv")
|
|
591
|
+
fs.copy_if_newer(self.templatePath / "TradeDangerous.sql", self.dataPath / "TradeDangerous.sql")
|
|
592
|
+
|
|
593
|
+
self.dbPath = Path(tdenv.dbFilename or dataPath / TradeDB.defaultDB)
|
|
594
|
+
self.sqlPath = dataPath / Path(tdenv.sqlFilename or TradeDB.defaultSQL)
|
|
595
|
+
# pricePath = Path(tdenv.pricesFilename or TradeDB.defaultPrices)
|
|
596
|
+
# self.pricesPath = dataPath / pricePath
|
|
597
|
+
|
|
598
|
+
# If the "pickle jar" file we used temporarily is present, delete it.
|
|
599
|
+
persist_file = Path(self.dataPath, TradeDB.persistFile)
|
|
600
|
+
persist_file.unlink(missing_ok=True)
|
|
601
|
+
|
|
602
|
+
self.importTables = [
|
|
603
|
+
(str(self.csvPath / Path(fn)), tn)
|
|
604
|
+
for fn, tn in TradeDB.defaultTables
|
|
605
|
+
]
|
|
606
|
+
self.importPaths = {tn: tp for tp, tn in self.importTables}
|
|
607
|
+
|
|
608
|
+
self.dbFilename = str(self.dbPath)
|
|
609
|
+
self.sqlFilename = str(self.sqlPath)
|
|
610
|
+
# self.pricesFilename = str(self.pricesPath)
|
|
611
|
+
|
|
612
|
+
# --- Cache attributes (unchanged) ---
|
|
613
|
+
self.avgSelling, self.avgBuying = None, None
|
|
614
|
+
self.tradingStationCount = 0
|
|
615
|
+
self.systemByID: dict[int, System] | None = None
|
|
616
|
+
self.systemByName: dict[str, list[System]] | None = None
|
|
617
|
+
self.stellarGrid: dict[tuple[int, int, int], list[System]] | None = None
|
|
618
|
+
self.stationByID: dict[int, Station] | None = None
|
|
619
|
+
self.categoryByID: dict[int, Category] | None = None
|
|
620
|
+
self.itemByID: dict[int, Item] | None = None
|
|
621
|
+
self.itemByName: dict[str, Item] | None = None
|
|
622
|
+
self.itemByFDevID: dict[int, Item] | None = None
|
|
623
|
+
|
|
624
|
+
# --- Engine bootstrap ---
|
|
625
|
+
|
|
626
|
+
# Determine user's real invocation directory, not venv/bin
|
|
627
|
+
user_cwd = Path(os.getenv("PWD", Path.cwd()))
|
|
628
|
+
data_dir = user_cwd / "data"
|
|
629
|
+
|
|
630
|
+
cfg = os.environ.get("TD_DB_CONFIG") or str(data_dir / "db_config.ini")
|
|
631
|
+
|
|
632
|
+
self.engine = make_engine_from_config(cfg)
|
|
633
|
+
self.Session = get_session_factory(self.engine)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# --- Initial load ---
|
|
637
|
+
if load:
|
|
638
|
+
self.reloadCache()
|
|
639
|
+
self.load()
|
|
640
|
+
|
|
641
|
+
# ------------------------------------------------------------------
|
|
642
|
+
# Legacy compatibility dataPath shim
|
|
643
|
+
# ------------------------------------------------------------------
|
|
644
|
+
@property
|
|
645
|
+
def dataDir(self) -> Path:
|
|
646
|
+
"""
|
|
647
|
+
Legacy alias for self.dataPath (removed in SQLAlchemy refactor).
|
|
648
|
+
Falls back to './data' if configuration not yet loaded.
|
|
649
|
+
"""
|
|
650
|
+
# Try the modern attribute first
|
|
651
|
+
if hasattr(self, "dataPath") and self.dataPath:
|
|
652
|
+
return self.dataPath
|
|
653
|
+
# If we have an environment object, use its dataDir
|
|
654
|
+
if hasattr(self, "tdenv") and getattr(self.tdenv, "dataDir", None):
|
|
655
|
+
return self.tdenv.dataDir
|
|
656
|
+
# Final fallback (first run, pre-bootstrap)
|
|
657
|
+
return Path("./data")
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
@staticmethod
|
|
661
|
+
def calculateDistance2(lx: float, ly: float, lz: float, rx: float, ry: float, rz: float) -> float:
|
|
662
|
+
""" calculateDistance2 returns the *square* (^2) of the euclidean
|
|
663
|
+
distance between two 3d coordinates. This is an optimization
|
|
664
|
+
for when you need to compare many coordinate pairs but do
|
|
665
|
+
not actually need to retain the distance value.
|
|
666
|
+
|
|
667
|
+
That is:
|
|
668
|
+
distance**2 <=> calculateDistance2(x,y,z, u,v,w)
|
|
669
|
+
always returns the same results as
|
|
670
|
+
distance <=> calculateDistance (x,y,z, u,v,w)
|
|
671
|
+
but is cheaper.
|
|
672
|
+
"""
|
|
673
|
+
dx, dy, dz = lx - rx, ly - ry, lz - rz
|
|
674
|
+
return (dx * dx) + (dy * dy) + (dz * dz)
|
|
675
|
+
|
|
676
|
+
@staticmethod
|
|
677
|
+
def calculateDistance(lx: float, ly: float, lz: float, rx: float, ry: float, rz: float) -> float:
|
|
678
|
+
"""
|
|
679
|
+
calculateDistance returns the euclidean distance in ly between two points.
|
|
680
|
+
@note When you are testing many pairs without retaining the calculated value
|
|
681
|
+
beyond the comparison, consider using calculateDistance2 instead.
|
|
682
|
+
"""
|
|
683
|
+
dx, dy, dz = lx - rx, ly - ry, lz - rz
|
|
684
|
+
return math_sqrt((dx * dx) + (dy * dy) + (dz * dz))
|
|
685
|
+
|
|
686
|
+
@staticmethod
|
|
687
|
+
def _split_system_index(name: str) -> tuple[str, int | None]:
|
|
688
|
+
"""
|
|
689
|
+
Split a trailing '@N' suffix from a system name, if present.
|
|
690
|
+
|
|
691
|
+
Examples:
|
|
692
|
+
'Lorionis-SOC 13@2' -> ('Lorionis-SOC 13', 2)
|
|
693
|
+
'Shinrarta Dezhra' -> ('Shinrarta Dezhra', None)
|
|
694
|
+
'@SOL' -> ('@SOL', None) # leading @ is a different annotation
|
|
695
|
+
|
|
696
|
+
Returns:
|
|
697
|
+
(base_name, index) where index is 1-based, or None if no valid suffix.
|
|
698
|
+
"""
|
|
699
|
+
# Ignore a leading '@' which is used as an explicit system/station annotation
|
|
700
|
+
at = name.rfind('@')
|
|
701
|
+
if at <= 0:
|
|
702
|
+
return name, None
|
|
703
|
+
|
|
704
|
+
idx_str = name[at + 1:]
|
|
705
|
+
if not idx_str or not idx_str.isdigit():
|
|
706
|
+
return name, None
|
|
707
|
+
|
|
708
|
+
base = name[:at]
|
|
709
|
+
return base, int(idx_str)
|
|
710
|
+
|
|
711
|
+
############################################################
|
|
712
|
+
# Access to the underlying database.
|
|
713
|
+
|
|
714
|
+
def getDB(self):
|
|
715
|
+
"""
|
|
716
|
+
Return a new SQLAlchemy Session bound to this TradeDB engine.
|
|
717
|
+
"""
|
|
718
|
+
if not self.engine:
|
|
719
|
+
raise TradeException("Database engine not initialised")
|
|
720
|
+
return self.Session()
|
|
721
|
+
|
|
722
|
+
def query(self, sql: str, *params):
|
|
723
|
+
"""
|
|
724
|
+
Execute a SQL statement via the SQLAlchemy engine and return the result cursor.
|
|
725
|
+
"""
|
|
726
|
+
with self.engine.connect() as conn:
|
|
727
|
+
return conn.execute(text(sql), params)
|
|
728
|
+
|
|
729
|
+
def queryColumn(self, sql: str, *params):
|
|
730
|
+
"""
|
|
731
|
+
Execute a SQL statement and return the first column of the first row.
|
|
732
|
+
"""
|
|
733
|
+
result = self.query(sql, *params).first()
|
|
734
|
+
return result[0] if result else None
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def reloadCache(self) -> None:
|
|
738
|
+
"""
|
|
739
|
+
Ensure DB is present and minimally populated using the central policy.
|
|
740
|
+
|
|
741
|
+
Delegates sanity checks to lifecycle.ensure_fresh_db (seconds-only checks):
|
|
742
|
+
- core tables exist (System, Station, Category, Item, StationItem)
|
|
743
|
+
- each has a primary key
|
|
744
|
+
- seed rows exist (Category > 0, System > 0)
|
|
745
|
+
- cheap connectivity probe
|
|
746
|
+
|
|
747
|
+
If checks fail (or lifecycle decides to force), it will call buildCache(self, self.tdenv)
|
|
748
|
+
to reset/populate via the authoritative path. Otherwise it is a no-op.
|
|
749
|
+
self.tdenv.DEBUG0("reloadCache: engine URL = {}", str(self.engine.url))
|
|
750
|
+
"""
|
|
751
|
+
|
|
752
|
+
try:
|
|
753
|
+
summary = ensure_fresh_db(
|
|
754
|
+
backend=self.engine.dialect.name,
|
|
755
|
+
engine=self.engine,
|
|
756
|
+
data_dir=self.dataPath,
|
|
757
|
+
metadata=None,
|
|
758
|
+
mode="auto",
|
|
759
|
+
tdb=self,
|
|
760
|
+
tdenv=self.tdenv,
|
|
761
|
+
)
|
|
762
|
+
action = summary.get("action", "kept")
|
|
763
|
+
reason = summary.get("reason")
|
|
764
|
+
if reason:
|
|
765
|
+
self.tdenv.DEBUG0("reloadCache: ensure_fresh_db → {} (reason: {})", action, reason)
|
|
766
|
+
else:
|
|
767
|
+
self.tdenv.DEBUG0("reloadCache: ensure_fresh_db → {}", action)
|
|
768
|
+
except Exception as e:
|
|
769
|
+
self.tdenv.WARN("reloadCache: ensure_fresh_db failed: {}", e)
|
|
770
|
+
self.tdenv.DEBUG0("reloadCache: Falling back to buildCache()")
|
|
771
|
+
cache.buildCache(self, self.tdenv)
|
|
772
|
+
|
|
773
|
+
############################################################
|
|
774
|
+
# [deprecated] "added" data.
|
|
775
|
+
|
|
776
|
+
def lookupAdded(self, name):
|
|
777
|
+
stmt = select(SA_Added.added_id).where(SA_Added.name == name)
|
|
778
|
+
with self.Session() as session:
|
|
779
|
+
try:
|
|
780
|
+
return session.execute(stmt).scalar_one()
|
|
781
|
+
except NoResultFound:
|
|
782
|
+
raise KeyError(name) from None
|
|
783
|
+
|
|
784
|
+
############################################################
|
|
785
|
+
# Star system data.
|
|
786
|
+
|
|
787
|
+
# TODO: Defer to SA_System as much as possible
|
|
788
|
+
def systems(self) -> Generator['System', Any, None]:
|
|
789
|
+
""" Iterate through the list of systems. """
|
|
790
|
+
yield from self.systemByID.values()
|
|
791
|
+
|
|
792
|
+
def _loadSystems(self) -> None:
|
|
793
|
+
"""
|
|
794
|
+
Initial load of the list of systems via SQLAlchemy.
|
|
795
|
+
CAUTION: Will orphan previously loaded objects.
|
|
796
|
+
"""
|
|
797
|
+
systemByID: dict[int, System] = {}
|
|
798
|
+
systemByName: dict[str, list[System]] = {}
|
|
799
|
+
started = time.time()
|
|
800
|
+
with self.Session() as session:
|
|
801
|
+
for row in session.query(
|
|
802
|
+
SA_System.system_id,
|
|
803
|
+
SA_System.name,
|
|
804
|
+
SA_System.pos_x,
|
|
805
|
+
SA_System.pos_y,
|
|
806
|
+
SA_System.pos_z,
|
|
807
|
+
SA_System.added_id,
|
|
808
|
+
):
|
|
809
|
+
system = System(
|
|
810
|
+
row.system_id,
|
|
811
|
+
row.name,
|
|
812
|
+
row.pos_x,
|
|
813
|
+
row.pos_y,
|
|
814
|
+
row.pos_z,
|
|
815
|
+
row.added_id,
|
|
816
|
+
)
|
|
817
|
+
systemByID[row.system_id] = system
|
|
818
|
+
key = system.dbname.upper()
|
|
819
|
+
bucket = systemByName.get(key)
|
|
820
|
+
if bucket is None:
|
|
821
|
+
systemByName[key] = [system]
|
|
822
|
+
else:
|
|
823
|
+
bucket.append(system)
|
|
824
|
+
|
|
825
|
+
# Ensure deterministic ordering for duplicate-name groups:
|
|
826
|
+
# sort by posX, then posY, posZ, ID so @1 is lowest X, stable.
|
|
827
|
+
for systems in systemByName.values():
|
|
828
|
+
systems.sort(key=lambda s: (s.posX, s.posY, s.posZ, s.ID))
|
|
829
|
+
|
|
830
|
+
self.systemByID = systemByID
|
|
831
|
+
self.systemByName = systemByName
|
|
832
|
+
self.tdenv.DEBUG1(
|
|
833
|
+
"Loaded {:n} Systems in {:.3f}s",
|
|
834
|
+
len(systemByID),
|
|
835
|
+
time.time() - started,
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def lookupSystem(self, name):
|
|
840
|
+
"""
|
|
841
|
+
Lookup a system by name or return a System object unchanged.
|
|
842
|
+
Accepts:
|
|
843
|
+
- System instance → returned directly
|
|
844
|
+
- Station instance → return station.system
|
|
845
|
+
- str → resolve by name, with @N disambiguation
|
|
846
|
+
"""
|
|
847
|
+
|
|
848
|
+
# NEW: accept already-resolved objects
|
|
849
|
+
if isinstance(name, System):
|
|
850
|
+
return name
|
|
851
|
+
if isinstance(name, Station):
|
|
852
|
+
return name.system
|
|
853
|
+
|
|
854
|
+
if not isinstance(name, str):
|
|
855
|
+
raise TypeError(
|
|
856
|
+
f"lookupSystem expects str/System/Station, got {type(name)!r}"
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
# From here on, name is guaranteed a string.
|
|
860
|
+
base_name, index = self._split_system_index(name)
|
|
861
|
+
base_key = base_name.upper()
|
|
862
|
+
|
|
863
|
+
try:
|
|
864
|
+
systems_list = self.systemByName[base_key]
|
|
865
|
+
except KeyError:
|
|
866
|
+
# Fall back to original partial-match behaviour.
|
|
867
|
+
return TradeDB.listSearch(
|
|
868
|
+
"System",
|
|
869
|
+
name,
|
|
870
|
+
self.systems(),
|
|
871
|
+
key=lambda system: system.dbname,
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
# No explicit index
|
|
875
|
+
if index is None:
|
|
876
|
+
if len(systems_list) == 1:
|
|
877
|
+
return systems_list[0]
|
|
878
|
+
if len(systems_list) > 1:
|
|
879
|
+
anyMatch = [
|
|
880
|
+
(i + 1, system)
|
|
881
|
+
for i, system in enumerate(systems_list)
|
|
882
|
+
]
|
|
883
|
+
raise AmbiguityError(
|
|
884
|
+
"System",
|
|
885
|
+
base_name,
|
|
886
|
+
anyMatch,
|
|
887
|
+
key=lambda entry: (
|
|
888
|
+
f"{entry[1].dbname}@{entry[0]} — "
|
|
889
|
+
f"({entry[1].posX:.1f}, {entry[1].posY:.1f}, {entry[1].posZ:.1f})"
|
|
890
|
+
),
|
|
891
|
+
)
|
|
892
|
+
raise LookupError(f'Error: "{name}" doesn\'t match any known System')
|
|
893
|
+
|
|
894
|
+
# Explicit @N index
|
|
895
|
+
if 1 <= index <= len(systems_list):
|
|
896
|
+
return systems_list[index - 1]
|
|
897
|
+
|
|
898
|
+
# Out-of-range index
|
|
899
|
+
count = len(systems_list)
|
|
900
|
+
header = f'System "{base_name}" has {count} matching entries (@1..@{count}).'
|
|
901
|
+
invalid_line = f'"{base_name}@{index}" is not a valid index.'
|
|
902
|
+
lines = [header, invalid_line, "", "Use one of the available forms:", ""]
|
|
903
|
+
for idx, system in enumerate(systems_list, start=1):
|
|
904
|
+
lines.append(
|
|
905
|
+
f" {system.dbname}@{idx} — "
|
|
906
|
+
f"({system.posX:.1f}, {system.posY:.1f}, {system.posZ:.1f})"
|
|
907
|
+
)
|
|
908
|
+
message = "\n".join(lines)
|
|
909
|
+
raise TradeException(message)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def addLocalSystem(
|
|
913
|
+
self,
|
|
914
|
+
name,
|
|
915
|
+
x, y, z,
|
|
916
|
+
modified='now',
|
|
917
|
+
commit=True,
|
|
918
|
+
) -> System:
|
|
919
|
+
"""
|
|
920
|
+
Add a system to the local cache and memory copy using SQLAlchemy.
|
|
921
|
+
Note: 'added' field has been deprecated and is no longer populated.
|
|
922
|
+
"""
|
|
923
|
+
with self.Session() as session:
|
|
924
|
+
# Create ORM System row (added_id is deprecated → NULL)
|
|
925
|
+
orm_system = SA_System(
|
|
926
|
+
name=name,
|
|
927
|
+
pos_x=x,
|
|
928
|
+
pos_y=y,
|
|
929
|
+
pos_z=z,
|
|
930
|
+
added_id=None,
|
|
931
|
+
modified=None if modified == 'now' else modified,
|
|
932
|
+
)
|
|
933
|
+
session.add(orm_system)
|
|
934
|
+
if commit:
|
|
935
|
+
session.commit()
|
|
936
|
+
else:
|
|
937
|
+
session.flush()
|
|
938
|
+
|
|
939
|
+
ID = orm_system.system_id
|
|
940
|
+
|
|
941
|
+
# Maintain legacy wrapper + caches (added_id always None now)
|
|
942
|
+
system = System(ID, name.upper(), x, y, z, None)
|
|
943
|
+
self.systemByID[ID] = system
|
|
944
|
+
|
|
945
|
+
key = system.dbname.upper()
|
|
946
|
+
bucket = self.systemByName.get(key)
|
|
947
|
+
if bucket is None:
|
|
948
|
+
self.systemByName[key] = [system]
|
|
949
|
+
else:
|
|
950
|
+
bucket.append(system)
|
|
951
|
+
bucket.sort(key=lambda s: (s.posX, s.posY, s.posZ, s.ID))
|
|
952
|
+
|
|
953
|
+
self.tdenv.NOTE(
|
|
954
|
+
"Added new system #{}: {} [{},{},{}]",
|
|
955
|
+
ID, name, x, y, z
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
return system
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def updateLocalSystem(
|
|
962
|
+
self, system,
|
|
963
|
+
name, x, y, z, added="Local", modified='now',
|
|
964
|
+
force=False,
|
|
965
|
+
commit=True,
|
|
966
|
+
) -> bool:
|
|
967
|
+
"""
|
|
968
|
+
Update an entry for a local system using SQLAlchemy.
|
|
969
|
+
"""
|
|
970
|
+
oldname = system.dbname
|
|
971
|
+
dbname = name.upper()
|
|
972
|
+
|
|
973
|
+
if not force:
|
|
974
|
+
if oldname == dbname and system.posX == x and system.posY == y and system.posZ == z:
|
|
975
|
+
return False
|
|
976
|
+
|
|
977
|
+
# Remove from old name bucket (if present)
|
|
978
|
+
old_key = oldname.upper()
|
|
979
|
+
bucket = self.systemByName.get(old_key)
|
|
980
|
+
if bucket is not None:
|
|
981
|
+
bucket = [s for s in bucket if s is not system]
|
|
982
|
+
if bucket:
|
|
983
|
+
self.systemByName[old_key] = bucket
|
|
984
|
+
else:
|
|
985
|
+
del self.systemByName[old_key]
|
|
986
|
+
|
|
987
|
+
with self.Session() as session:
|
|
988
|
+
# Find Added row for added_id
|
|
989
|
+
added_row = session.query(SA_Added).filter(SA_Added.name == added).first()
|
|
990
|
+
if not added_row:
|
|
991
|
+
raise TradeException(f"Added entry not found: {added}")
|
|
992
|
+
|
|
993
|
+
# Load ORM System row
|
|
994
|
+
orm_system = session.get(SA_System, system.ID)
|
|
995
|
+
if not orm_system:
|
|
996
|
+
raise TradeException(f"System ID not found: {system.ID}")
|
|
997
|
+
|
|
998
|
+
# Apply updates
|
|
999
|
+
orm_system.name = dbname
|
|
1000
|
+
orm_system.pos_x = x
|
|
1001
|
+
orm_system.pos_y = y
|
|
1002
|
+
orm_system.pos_z = z
|
|
1003
|
+
orm_system.added_id = added_row.added_id
|
|
1004
|
+
orm_system.modified = None if modified == 'now' else modified
|
|
1005
|
+
|
|
1006
|
+
if commit:
|
|
1007
|
+
session.commit()
|
|
1008
|
+
else:
|
|
1009
|
+
session.flush()
|
|
1010
|
+
|
|
1011
|
+
self.tdenv.NOTE(
|
|
1012
|
+
"{} (#{}) updated in {}: {}, {}, {}, {}, {}, {}",
|
|
1013
|
+
oldname, system.ID,
|
|
1014
|
+
self.dbPath if self.tdenv.detail > 1 else "local db",
|
|
1015
|
+
dbname, x, y, z, added, modified,
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
# Update wrapper caches
|
|
1019
|
+
system.dbname = dbname
|
|
1020
|
+
system.posX, system.posY, system.posZ = x, y, z
|
|
1021
|
+
system.addedID = added_row.added_id
|
|
1022
|
+
|
|
1023
|
+
# Add to new name bucket
|
|
1024
|
+
new_key = dbname.upper()
|
|
1025
|
+
bucket = self.systemByName.get(new_key)
|
|
1026
|
+
if bucket is None:
|
|
1027
|
+
self.systemByName[new_key] = [system]
|
|
1028
|
+
else:
|
|
1029
|
+
bucket.append(system)
|
|
1030
|
+
bucket.sort(key=lambda s: (s.posX, s.posY, s.posZ, s.ID))
|
|
1031
|
+
|
|
1032
|
+
return True
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def removeLocalSystem(
|
|
1036
|
+
self, system,
|
|
1037
|
+
commit=True,
|
|
1038
|
+
):
|
|
1039
|
+
"""Remove a system and its stations from the local DB using SQLAlchemy."""
|
|
1040
|
+
# First remove stations attached to this system
|
|
1041
|
+
for stn in self.stations():
|
|
1042
|
+
if stn.system == system:
|
|
1043
|
+
self.removeLocalStation(stn, commit=False)
|
|
1044
|
+
|
|
1045
|
+
with self.Session() as session:
|
|
1046
|
+
orm_system = session.get(SA_System, system.ID)
|
|
1047
|
+
if orm_system:
|
|
1048
|
+
session.delete(orm_system)
|
|
1049
|
+
if commit:
|
|
1050
|
+
session.commit()
|
|
1051
|
+
else:
|
|
1052
|
+
session.flush()
|
|
1053
|
+
|
|
1054
|
+
# Update caches: remove from name bucket and ID map
|
|
1055
|
+
key = system.dbname.upper()
|
|
1056
|
+
bucket = self.systemByName.get(key)
|
|
1057
|
+
if bucket is not None:
|
|
1058
|
+
bucket = [s for s in bucket if s is not system]
|
|
1059
|
+
if bucket:
|
|
1060
|
+
self.systemByName[key] = bucket
|
|
1061
|
+
else:
|
|
1062
|
+
del self.systemByName[key]
|
|
1063
|
+
del self.systemByID[system.ID]
|
|
1064
|
+
|
|
1065
|
+
self.tdenv.NOTE(
|
|
1066
|
+
"{} (#{}) deleted from {}",
|
|
1067
|
+
system.dbname, system.ID,
|
|
1068
|
+
self.dbPath if self.tdenv.detail > 1 else "local db",
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
system.dbname = "DELETED " + system.dbname
|
|
1072
|
+
del system
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def __buildStellarGrid(self) -> None:
|
|
1076
|
+
"""
|
|
1077
|
+
Divides the galaxy into a fixed-sized grid allowing us to
|
|
1078
|
+
aggregate small numbers of stars by locality.
|
|
1079
|
+
"""
|
|
1080
|
+
stellarGrid: dict[tuple[int, int, int], list[System]] = {}
|
|
1081
|
+
if not self.systemByID:
|
|
1082
|
+
raise RuntimeError("Stellar grid building requires systems to be pre-loaded")
|
|
1083
|
+
for system in self.systemByID.values():
|
|
1084
|
+
key = make_stellar_grid_key(system.posX, system.posY, system.posZ)
|
|
1085
|
+
try:
|
|
1086
|
+
stellarGrid[key].append(system)
|
|
1087
|
+
except KeyError:
|
|
1088
|
+
stellarGrid[key] = [system]
|
|
1089
|
+
self.stellarGrid = stellarGrid
|
|
1090
|
+
|
|
1091
|
+
def genStellarGrid(self, system: 'System', ly: float):
|
|
1092
|
+
"""
|
|
1093
|
+
Yields Systems within a given radius of a specified System.
|
|
1094
|
+
|
|
1095
|
+
Args:
|
|
1096
|
+
system:
|
|
1097
|
+
The System to center the search on,
|
|
1098
|
+
ly:
|
|
1099
|
+
The radius of the search around system,
|
|
1100
|
+
|
|
1101
|
+
Yields:
|
|
1102
|
+
(candidate, distLySq)
|
|
1103
|
+
candidate:
|
|
1104
|
+
System that was found,
|
|
1105
|
+
distLySq:
|
|
1106
|
+
The *SQUARE* of the distance in light-years
|
|
1107
|
+
between system and candidate.
|
|
1108
|
+
"""
|
|
1109
|
+
if self.stellarGrid is None:
|
|
1110
|
+
self.__buildStellarGrid()
|
|
1111
|
+
|
|
1112
|
+
sysX, sysY, sysZ = system.posX, system.posY, system.posZ
|
|
1113
|
+
lwrBound = make_stellar_grid_key(sysX - ly, sysY - ly, sysZ - ly)
|
|
1114
|
+
uprBound = make_stellar_grid_key(sysX + ly, sysY + ly, sysZ + ly)
|
|
1115
|
+
lySq = ly * ly # in 64-bit python, ** invokes a function call making it 4x expensive as *.
|
|
1116
|
+
stellarGrid = self.stellarGrid
|
|
1117
|
+
for x in range(lwrBound[0], uprBound[0]+1):
|
|
1118
|
+
for y in range(lwrBound[1], uprBound[1]+1):
|
|
1119
|
+
for z in range(lwrBound[2], uprBound[2]+1):
|
|
1120
|
+
try:
|
|
1121
|
+
grid = stellarGrid[(x, y, z)]
|
|
1122
|
+
except KeyError:
|
|
1123
|
+
continue
|
|
1124
|
+
for candidate in grid:
|
|
1125
|
+
delta = candidate.posX - sysX
|
|
1126
|
+
distSq = delta * delta
|
|
1127
|
+
if distSq > lySq:
|
|
1128
|
+
continue
|
|
1129
|
+
delta = candidate.posY - sysY
|
|
1130
|
+
distSq += delta * delta
|
|
1131
|
+
if distSq > lySq:
|
|
1132
|
+
continue
|
|
1133
|
+
delta = candidate.posZ - sysZ
|
|
1134
|
+
distSq += delta * delta
|
|
1135
|
+
if distSq > lySq:
|
|
1136
|
+
continue
|
|
1137
|
+
if candidate is not system:
|
|
1138
|
+
yield candidate, math_sqrt(distSq)
|
|
1139
|
+
|
|
1140
|
+
def genSystemsInRange(self, system: 'System', ly: float, includeSelf: bool = False)-> Generator[tuple[System, float], Any, None]:
|
|
1141
|
+
"""
|
|
1142
|
+
Yields Systems within a given radius of a specified System.
|
|
1143
|
+
Results are sorted by distance and cached for subsequent
|
|
1144
|
+
queries in the same run.
|
|
1145
|
+
|
|
1146
|
+
Args:
|
|
1147
|
+
system:
|
|
1148
|
+
The System to center the search on,
|
|
1149
|
+
ly:
|
|
1150
|
+
The radius of the search around system,
|
|
1151
|
+
includeSelf:
|
|
1152
|
+
Whether to include 'system' in the results or not.
|
|
1153
|
+
|
|
1154
|
+
Yields:
|
|
1155
|
+
(candidate, distLy)
|
|
1156
|
+
candidate:
|
|
1157
|
+
System that was found,
|
|
1158
|
+
distLy:
|
|
1159
|
+
The distance in lightyears between system and candidate.
|
|
1160
|
+
"""
|
|
1161
|
+
|
|
1162
|
+
cur_cache = system._rangeCache # pylint: disable=protected-access # noqa: SLF001
|
|
1163
|
+
if not cur_cache:
|
|
1164
|
+
cur_cache = system._rangeCache = System.RangeCache() # pylint: disable=protected-access # noqa: SLF001
|
|
1165
|
+
cached_systems = cur_cache.systems
|
|
1166
|
+
|
|
1167
|
+
if ly > cur_cache.probed_ly:
|
|
1168
|
+
# Consult the database for stars we haven't seen.
|
|
1169
|
+
cached_systems = cur_cache.systems = list(
|
|
1170
|
+
self.genStellarGrid(system, ly)
|
|
1171
|
+
)
|
|
1172
|
+
cached_systems.sort(key=lambda ent: ent[1])
|
|
1173
|
+
cur_cache.probed_ly = ly
|
|
1174
|
+
|
|
1175
|
+
if includeSelf:
|
|
1176
|
+
yield system, 0.
|
|
1177
|
+
|
|
1178
|
+
if cur_cache.probed_ly > ly:
|
|
1179
|
+
# Cache may contain values outside our view
|
|
1180
|
+
for candidate, dist in cached_systems:
|
|
1181
|
+
if dist <= ly:
|
|
1182
|
+
yield candidate, dist
|
|
1183
|
+
else:
|
|
1184
|
+
# No need to be conditional inside the loop
|
|
1185
|
+
yield from cached_systems
|
|
1186
|
+
|
|
1187
|
+
def getRoute(self, origin, dest, maxJumpLy, avoiding=None, stationInterval=0):
|
|
1188
|
+
"""
|
|
1189
|
+
Find a shortest route between two systems with an additional
|
|
1190
|
+
constraint that each system be a maximum of maxJumpLy from
|
|
1191
|
+
the previous system.
|
|
1192
|
+
|
|
1193
|
+
Args:
|
|
1194
|
+
origin:
|
|
1195
|
+
System (or station) to start from,
|
|
1196
|
+
dest:
|
|
1197
|
+
System (or station) to terminate at,
|
|
1198
|
+
maxJumpLy:
|
|
1199
|
+
Maximum light years between systems,
|
|
1200
|
+
avoiding:
|
|
1201
|
+
List of systems being avoided
|
|
1202
|
+
stationInterval:
|
|
1203
|
+
If non-zero, require a station at least this many jumps,
|
|
1204
|
+
tdenv.padSize:
|
|
1205
|
+
Controls the pad size of stations for refuelling
|
|
1206
|
+
|
|
1207
|
+
Returns:
|
|
1208
|
+
None
|
|
1209
|
+
No route was found
|
|
1210
|
+
|
|
1211
|
+
[(origin, 0),...(dest, N)]
|
|
1212
|
+
A list of (system, distanceSoFar) values describing
|
|
1213
|
+
the route.
|
|
1214
|
+
|
|
1215
|
+
Example:
|
|
1216
|
+
If there are systems A, B and C such
|
|
1217
|
+
that A->B is 7ly and B->C is 8ly then:
|
|
1218
|
+
|
|
1219
|
+
origin = lookupPlace("A")
|
|
1220
|
+
dest = lookupPlace("C")
|
|
1221
|
+
route = tdb.getRoute(origin, dest, 9)
|
|
1222
|
+
|
|
1223
|
+
The route should be:
|
|
1224
|
+
|
|
1225
|
+
[(System(A), 0), (System(B), 7), System(C), 15)]
|
|
1226
|
+
|
|
1227
|
+
"""
|
|
1228
|
+
|
|
1229
|
+
if avoiding is None:
|
|
1230
|
+
avoiding = []
|
|
1231
|
+
|
|
1232
|
+
if isinstance(origin, Station):
|
|
1233
|
+
origin = origin.system
|
|
1234
|
+
if isinstance(dest, Station):
|
|
1235
|
+
dest = dest.system
|
|
1236
|
+
|
|
1237
|
+
if origin == dest:
|
|
1238
|
+
return ((origin, 0), (dest, 0))
|
|
1239
|
+
|
|
1240
|
+
# openSet is the list of nodes we want to visit, which will be
|
|
1241
|
+
# used as a priority queue (heapq).
|
|
1242
|
+
# Each element is a tuple of the 'priority' (the combination of
|
|
1243
|
+
# the total distance to the node and the distance left from the
|
|
1244
|
+
# node to the destination.
|
|
1245
|
+
openSet = [(0, 0, origin.ID, 0)]
|
|
1246
|
+
# Track predecessor nodes for everwhere we visit
|
|
1247
|
+
distances = {origin: (None, 0)}
|
|
1248
|
+
|
|
1249
|
+
if avoiding:
|
|
1250
|
+
if dest in avoiding:
|
|
1251
|
+
raise ValueError("Destination is in avoidance list")
|
|
1252
|
+
for avoid in avoiding:
|
|
1253
|
+
if isinstance(avoid, System):
|
|
1254
|
+
distances[avoid] = (None, -1)
|
|
1255
|
+
|
|
1256
|
+
systemsInRange = self.genSystemsInRange
|
|
1257
|
+
heappop = heapq.heappop
|
|
1258
|
+
heappush = heapq.heappush
|
|
1259
|
+
distTo = float("inf")
|
|
1260
|
+
defaultDist = (None, distTo)
|
|
1261
|
+
getDist = distances.get
|
|
1262
|
+
|
|
1263
|
+
destID = dest.ID
|
|
1264
|
+
sysByID = self.systemByID
|
|
1265
|
+
|
|
1266
|
+
maxPadSize = self.tdenv.padSize
|
|
1267
|
+
if not maxPadSize:
|
|
1268
|
+
def checkStations(system: System) -> bool: # pylint: disable=function-redefined, missing-docstring
|
|
1269
|
+
return bool(system.stations())
|
|
1270
|
+
else:
|
|
1271
|
+
def checkStations(system: System) -> bool: # pylint: disable=function-redefined, missing-docstring
|
|
1272
|
+
return any(stn for stn in system.stations if stn.checkPadSize(maxPadSize))
|
|
1273
|
+
|
|
1274
|
+
while openSet:
|
|
1275
|
+
weight, curDist, curSysID, stnDist = heappop(openSet)
|
|
1276
|
+
# If we reached 'goal' we've found the shortest path.
|
|
1277
|
+
if curSysID == destID:
|
|
1278
|
+
break
|
|
1279
|
+
if curDist >= distTo:
|
|
1280
|
+
continue
|
|
1281
|
+
curSys = sysByID[curSysID]
|
|
1282
|
+
# A node might wind up multiple times on the open list,
|
|
1283
|
+
# so check if we've already found a shorter distance to
|
|
1284
|
+
# the system and if so, ignore it this time.
|
|
1285
|
+
if curDist > distances[curSys][1]:
|
|
1286
|
+
continue
|
|
1287
|
+
|
|
1288
|
+
system_iter = iter(systemsInRange(curSys, maxJumpLy))
|
|
1289
|
+
if stationInterval:
|
|
1290
|
+
if checkStations(curSys):
|
|
1291
|
+
stnDist = 0
|
|
1292
|
+
else:
|
|
1293
|
+
stnDist += 1
|
|
1294
|
+
if stnDist >= stationInterval:
|
|
1295
|
+
system_iter = iter(
|
|
1296
|
+
v for v in system_iter if checkStations(v[0])
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
distFn = curSys.distanceTo
|
|
1300
|
+
for nSys, nDist in system_iter:
|
|
1301
|
+
newDist = curDist + nDist
|
|
1302
|
+
if getDist(nSys, defaultDist)[1] <= newDist:
|
|
1303
|
+
continue
|
|
1304
|
+
distances[nSys] = (curSys, newDist)
|
|
1305
|
+
weight = distFn(nSys)
|
|
1306
|
+
nID = nSys.ID
|
|
1307
|
+
heappush(openSet, (newDist + weight, newDist, nID, stnDist))
|
|
1308
|
+
if nID == destID:
|
|
1309
|
+
distTo = newDist
|
|
1310
|
+
|
|
1311
|
+
if dest not in distances:
|
|
1312
|
+
return None
|
|
1313
|
+
|
|
1314
|
+
path = []
|
|
1315
|
+
|
|
1316
|
+
while True:
|
|
1317
|
+
(prevSys, dist) = getDist(dest)
|
|
1318
|
+
path.append((dest, dist))
|
|
1319
|
+
if dest == origin:
|
|
1320
|
+
break
|
|
1321
|
+
dest = prevSys
|
|
1322
|
+
|
|
1323
|
+
path.reverse()
|
|
1324
|
+
|
|
1325
|
+
return path
|
|
1326
|
+
|
|
1327
|
+
############################################################
|
|
1328
|
+
# Station data.
|
|
1329
|
+
|
|
1330
|
+
def stations(self) -> 'Generator[Station, None, None]':
|
|
1331
|
+
""" Iterate through the list of stations. """
|
|
1332
|
+
yield from self.stationByID.values()
|
|
1333
|
+
|
|
1334
|
+
def _loadStations(self):
|
|
1335
|
+
"""
|
|
1336
|
+
Populate the Station list using SQLAlchemy.
|
|
1337
|
+
Station constructor automatically adds itself to the System object.
|
|
1338
|
+
CAUTION: Will orphan previously loaded objects.
|
|
1339
|
+
"""
|
|
1340
|
+
# NOTE: Requires module-level import:
|
|
1341
|
+
# from tradedangerous.db.utils import age_in_days
|
|
1342
|
+
stationByID = {}
|
|
1343
|
+
systemByID = self.systemByID
|
|
1344
|
+
self.tradingStationCount = 0
|
|
1345
|
+
|
|
1346
|
+
# Fleet Carriers are station type 24.
|
|
1347
|
+
# Odyssey settlements are station type 25.
|
|
1348
|
+
# Assume type 0 (Unknown) are also Fleet Carriers.
|
|
1349
|
+
carrier_types = (24, 0)
|
|
1350
|
+
odyssey_type = 25
|
|
1351
|
+
cached_system = None
|
|
1352
|
+
cached_system_id = None
|
|
1353
|
+
|
|
1354
|
+
started = time.time()
|
|
1355
|
+
with self.Session() as session:
|
|
1356
|
+
# Query all stations
|
|
1357
|
+
rows = session.query(
|
|
1358
|
+
SA_Station.station_id,
|
|
1359
|
+
SA_Station.system_id,
|
|
1360
|
+
SA_Station.name,
|
|
1361
|
+
SA_Station.ls_from_star,
|
|
1362
|
+
SA_Station.market,
|
|
1363
|
+
SA_Station.blackmarket,
|
|
1364
|
+
SA_Station.shipyard,
|
|
1365
|
+
SA_Station.max_pad_size,
|
|
1366
|
+
SA_Station.outfitting,
|
|
1367
|
+
SA_Station.rearm,
|
|
1368
|
+
SA_Station.refuel,
|
|
1369
|
+
SA_Station.repair,
|
|
1370
|
+
SA_Station.planetary,
|
|
1371
|
+
SA_Station.type_id,
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
for (
|
|
1375
|
+
ID, systemID, name,
|
|
1376
|
+
lsFromStar, market, blackMarket, shipyard,
|
|
1377
|
+
maxPadSize, outfitting, rearm, refuel, repair, planetary, type_id
|
|
1378
|
+
) in rows:
|
|
1379
|
+
isFleet = 'Y' if type_id in carrier_types else 'N'
|
|
1380
|
+
isOdyssey = 'Y' if type_id == odyssey_type else 'N'
|
|
1381
|
+
if systemID != cached_system_id:
|
|
1382
|
+
cached_system_id = systemID
|
|
1383
|
+
cached_system = systemByID[cached_system_id]
|
|
1384
|
+
stationByID[ID] = Station(
|
|
1385
|
+
ID, cached_system, name,
|
|
1386
|
+
lsFromStar, market, blackMarket, shipyard,
|
|
1387
|
+
maxPadSize, outfitting, rearm, refuel, repair,
|
|
1388
|
+
planetary, isFleet, isOdyssey,
|
|
1389
|
+
0, None,
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
# Trading station info
|
|
1393
|
+
tradingCount = 0
|
|
1394
|
+
rows = (
|
|
1395
|
+
session.query(
|
|
1396
|
+
SA_StationItem.station_id,
|
|
1397
|
+
func.count().label("item_count"),
|
|
1398
|
+
# Dialect-safe average age in **days**
|
|
1399
|
+
func.avg(age_in_days(session, SA_StationItem.modified)).label("data_age_days"),
|
|
1400
|
+
)
|
|
1401
|
+
.group_by(SA_StationItem.station_id)
|
|
1402
|
+
.having(func.count() > 0)
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
for ID, itemCount, dataAge in rows:
|
|
1406
|
+
station = stationByID[ID]
|
|
1407
|
+
station.itemCount = itemCount
|
|
1408
|
+
station.dataAge = dataAge
|
|
1409
|
+
tradingCount += 1
|
|
1410
|
+
|
|
1411
|
+
self.stationByID = stationByID
|
|
1412
|
+
self.tradingStationCount = tradingCount
|
|
1413
|
+
self.tdenv.DEBUG1("Loaded {:n} Stations in {:.3f}s", len(stationByID), (time.time() - started) * 1000)
|
|
1414
|
+
self.stellarGrid = None
|
|
1415
|
+
|
|
1416
|
+
def addLocalStation(
|
|
1417
|
+
self,
|
|
1418
|
+
system,
|
|
1419
|
+
name,
|
|
1420
|
+
lsFromStar,
|
|
1421
|
+
market,
|
|
1422
|
+
blackMarket,
|
|
1423
|
+
shipyard,
|
|
1424
|
+
maxPadSize,
|
|
1425
|
+
outfitting,
|
|
1426
|
+
rearm,
|
|
1427
|
+
refuel,
|
|
1428
|
+
repair,
|
|
1429
|
+
planetary,
|
|
1430
|
+
fleet,
|
|
1431
|
+
odyssey,
|
|
1432
|
+
modified='now',
|
|
1433
|
+
commit=True,
|
|
1434
|
+
):
|
|
1435
|
+
"""
|
|
1436
|
+
Add a station to the local cache and memory copy using SQLAlchemy.
|
|
1437
|
+
"""
|
|
1438
|
+
# Normalise/validate inputs
|
|
1439
|
+
market = market.upper()
|
|
1440
|
+
blackMarket = blackMarket.upper()
|
|
1441
|
+
shipyard = shipyard.upper()
|
|
1442
|
+
maxPadSize = maxPadSize.upper()
|
|
1443
|
+
outfitting = outfitting.upper()
|
|
1444
|
+
rearm = rearm.upper()
|
|
1445
|
+
refuel = refuel.upper()
|
|
1446
|
+
repair = repair.upper()
|
|
1447
|
+
planetary = planetary.upper()
|
|
1448
|
+
assert market in "?YN"
|
|
1449
|
+
assert blackMarket in "?YN"
|
|
1450
|
+
assert shipyard in "?YN"
|
|
1451
|
+
assert maxPadSize in "?SML"
|
|
1452
|
+
assert outfitting in "?YN"
|
|
1453
|
+
assert rearm in "?YN"
|
|
1454
|
+
assert refuel in "?YN"
|
|
1455
|
+
assert repair in "?YN"
|
|
1456
|
+
assert planetary in "?YN"
|
|
1457
|
+
assert fleet in "?YN"
|
|
1458
|
+
assert odyssey in "?YN"
|
|
1459
|
+
|
|
1460
|
+
# Type mapping
|
|
1461
|
+
type_id = 0
|
|
1462
|
+
if fleet == 'Y':
|
|
1463
|
+
type_id = 24
|
|
1464
|
+
if odyssey == 'Y':
|
|
1465
|
+
type_id = 25
|
|
1466
|
+
|
|
1467
|
+
with self.Session() as session:
|
|
1468
|
+
orm_station = SA_Station(
|
|
1469
|
+
name=name,
|
|
1470
|
+
system_id=system.ID,
|
|
1471
|
+
ls_from_star=lsFromStar,
|
|
1472
|
+
market=market,
|
|
1473
|
+
blackmarket=blackMarket,
|
|
1474
|
+
shipyard=shipyard,
|
|
1475
|
+
max_pad_size=maxPadSize,
|
|
1476
|
+
outfitting=outfitting,
|
|
1477
|
+
rearm=rearm,
|
|
1478
|
+
refuel=refuel,
|
|
1479
|
+
repair=repair,
|
|
1480
|
+
planetary=planetary,
|
|
1481
|
+
type_id=type_id,
|
|
1482
|
+
modified=None if modified == 'now' else modified,
|
|
1483
|
+
)
|
|
1484
|
+
session.add(orm_station)
|
|
1485
|
+
if commit:
|
|
1486
|
+
session.commit()
|
|
1487
|
+
else:
|
|
1488
|
+
session.flush()
|
|
1489
|
+
ID = orm_station.station_id
|
|
1490
|
+
|
|
1491
|
+
# Legacy wrapper object
|
|
1492
|
+
station = Station(
|
|
1493
|
+
ID, system, name,
|
|
1494
|
+
lsFromStar=lsFromStar,
|
|
1495
|
+
market=market,
|
|
1496
|
+
blackMarket=blackMarket,
|
|
1497
|
+
shipyard=shipyard,
|
|
1498
|
+
maxPadSize=maxPadSize,
|
|
1499
|
+
outfitting=outfitting,
|
|
1500
|
+
rearm=rearm,
|
|
1501
|
+
refuel=refuel,
|
|
1502
|
+
repair=repair,
|
|
1503
|
+
planetary=planetary,
|
|
1504
|
+
fleet=fleet,
|
|
1505
|
+
odyssey=odyssey,
|
|
1506
|
+
itemCount=0,
|
|
1507
|
+
dataAge=0,
|
|
1508
|
+
)
|
|
1509
|
+
self.stationByID[ID] = station
|
|
1510
|
+
|
|
1511
|
+
self.tdenv.NOTE(
|
|
1512
|
+
"{} (#{}) added to {}: "
|
|
1513
|
+
"ls={}, mkt={}, bm={}, yard={}, pad={}, "
|
|
1514
|
+
"out={}, arm={}, ref={}, rep={}, plt={}, "
|
|
1515
|
+
"mod={}",
|
|
1516
|
+
station.name(), station.ID,
|
|
1517
|
+
self.dbPath if self.tdenv.detail > 1 else "local db",
|
|
1518
|
+
lsFromStar, market, blackMarket, shipyard, maxPadSize,
|
|
1519
|
+
outfitting, rearm, refuel, repair, planetary,
|
|
1520
|
+
modified,
|
|
1521
|
+
)
|
|
1522
|
+
return station
|
|
1523
|
+
|
|
1524
|
+
def updateLocalStation(
|
|
1525
|
+
self, station,
|
|
1526
|
+
name=None,
|
|
1527
|
+
lsFromStar=None,
|
|
1528
|
+
market=None,
|
|
1529
|
+
blackMarket=None,
|
|
1530
|
+
shipyard=None,
|
|
1531
|
+
maxPadSize=None,
|
|
1532
|
+
outfitting=None,
|
|
1533
|
+
rearm=None,
|
|
1534
|
+
refuel=None,
|
|
1535
|
+
repair=None,
|
|
1536
|
+
planetary=None,
|
|
1537
|
+
fleet=None,
|
|
1538
|
+
odyssey=None,
|
|
1539
|
+
modified='now',
|
|
1540
|
+
force=False,
|
|
1541
|
+
commit=True,
|
|
1542
|
+
):
|
|
1543
|
+
"""
|
|
1544
|
+
Alter the properties of a station in-memory and in the DB using SQLAlchemy.
|
|
1545
|
+
"""
|
|
1546
|
+
changes = []
|
|
1547
|
+
|
|
1548
|
+
def _changed(label, old, new):
|
|
1549
|
+
changes.append(f"{label}('{old}'=>'{new}')")
|
|
1550
|
+
|
|
1551
|
+
# Mutate wrapper + record changes
|
|
1552
|
+
if name is not None:
|
|
1553
|
+
if force or name.upper() != station.dbname.upper():
|
|
1554
|
+
_changed("name", station.dbname, name)
|
|
1555
|
+
station.dbname = name
|
|
1556
|
+
|
|
1557
|
+
if lsFromStar is not None:
|
|
1558
|
+
assert lsFromStar >= 0
|
|
1559
|
+
if lsFromStar != station.lsFromStar:
|
|
1560
|
+
if lsFromStar > 0 or force:
|
|
1561
|
+
_changed("ls", station.lsFromStar, lsFromStar)
|
|
1562
|
+
station.lsFromStar = lsFromStar
|
|
1563
|
+
|
|
1564
|
+
def _check_setting(label, attr_name, newValue, allowed):
|
|
1565
|
+
if newValue is not None:
|
|
1566
|
+
newValue = newValue.upper()
|
|
1567
|
+
assert newValue in allowed
|
|
1568
|
+
oldValue = getattr(station, attr_name, '?')
|
|
1569
|
+
if newValue != oldValue and (force or newValue != '?'):
|
|
1570
|
+
_changed(label, oldValue, newValue)
|
|
1571
|
+
setattr(station, attr_name, newValue)
|
|
1572
|
+
|
|
1573
|
+
_check_setting("pad", "maxPadSize", maxPadSize, TradeDB.padSizes)
|
|
1574
|
+
_check_setting("mkt", "market", market, TradeDB.marketStates)
|
|
1575
|
+
_check_setting("blk", "blackMarket", blackMarket, TradeDB.marketStates)
|
|
1576
|
+
_check_setting("shp", "shipyard", shipyard, TradeDB.marketStates)
|
|
1577
|
+
_check_setting("out", "outfitting", outfitting, TradeDB.marketStates)
|
|
1578
|
+
_check_setting("arm", "rearm", rearm, TradeDB.marketStates)
|
|
1579
|
+
_check_setting("ref", "refuel", refuel, TradeDB.marketStates)
|
|
1580
|
+
_check_setting("rep", "repair", repair, TradeDB.marketStates)
|
|
1581
|
+
_check_setting("plt", "planetary", planetary, TradeDB.planetStates)
|
|
1582
|
+
_check_setting("flc", "fleet", fleet, TradeDB.fleetStates)
|
|
1583
|
+
_check_setting("ody", "odyssey", odyssey, TradeDB.odysseyStates)
|
|
1584
|
+
|
|
1585
|
+
if not changes:
|
|
1586
|
+
return False
|
|
1587
|
+
|
|
1588
|
+
with self.Session() as session:
|
|
1589
|
+
orm_station = session.get(SA_Station, station.ID)
|
|
1590
|
+
if not orm_station:
|
|
1591
|
+
raise TradeException(f"Station ID not found: {station.ID}")
|
|
1592
|
+
|
|
1593
|
+
orm_station.name = station.dbname
|
|
1594
|
+
orm_station.system_id = station.system.ID
|
|
1595
|
+
orm_station.ls_from_star = station.lsFromStar
|
|
1596
|
+
orm_station.market = station.market
|
|
1597
|
+
orm_station.blackmarket = station.blackMarket
|
|
1598
|
+
orm_station.shipyard = station.shipyard
|
|
1599
|
+
orm_station.max_pad_size = station.maxPadSize
|
|
1600
|
+
orm_station.outfitting = station.outfitting
|
|
1601
|
+
orm_station.rearm = station.rearm
|
|
1602
|
+
orm_station.refuel = station.refuel
|
|
1603
|
+
orm_station.repair = station.repair
|
|
1604
|
+
orm_station.planetary = station.planetary
|
|
1605
|
+
orm_station.type_id = (
|
|
1606
|
+
24 if station.fleet == 'Y' else
|
|
1607
|
+
25 if station.odyssey == 'Y' else 0
|
|
1608
|
+
)
|
|
1609
|
+
orm_station.modified = None if modified == 'now' else modified
|
|
1610
|
+
|
|
1611
|
+
if commit:
|
|
1612
|
+
session.commit()
|
|
1613
|
+
else:
|
|
1614
|
+
session.flush()
|
|
1615
|
+
|
|
1616
|
+
self.tdenv.NOTE(
|
|
1617
|
+
"{} (#{}) updated in {}: {}",
|
|
1618
|
+
station.name(), station.ID,
|
|
1619
|
+
self.dbPath if self.tdenv.detail > 1 else "local db",
|
|
1620
|
+
", ".join(changes)
|
|
1621
|
+
)
|
|
1622
|
+
|
|
1623
|
+
return True
|
|
1624
|
+
|
|
1625
|
+
def removeLocalStation(self, station, commit=True):
|
|
1626
|
+
"""
|
|
1627
|
+
Remove a station from the local database and memory image using SQLAlchemy.
|
|
1628
|
+
Be careful of any references to the station you may still have after this.
|
|
1629
|
+
"""
|
|
1630
|
+
# Remove reference from parent system (wrapper-level)
|
|
1631
|
+
system = station.system
|
|
1632
|
+
if station in system.stations:
|
|
1633
|
+
system.stations.remove(station)
|
|
1634
|
+
|
|
1635
|
+
# Remove from ID lookup cache
|
|
1636
|
+
if station.ID in self.stationByID:
|
|
1637
|
+
del self.stationByID[station.ID]
|
|
1638
|
+
|
|
1639
|
+
# Delete from DB
|
|
1640
|
+
with self.Session() as session:
|
|
1641
|
+
orm_station = session.get(SA_Station, station.ID)
|
|
1642
|
+
if orm_station:
|
|
1643
|
+
session.delete(orm_station)
|
|
1644
|
+
if commit:
|
|
1645
|
+
session.commit()
|
|
1646
|
+
else:
|
|
1647
|
+
session.flush()
|
|
1648
|
+
|
|
1649
|
+
self.tdenv.NOTE(
|
|
1650
|
+
"{} (#{}) deleted from {}",
|
|
1651
|
+
station.name(), station.ID,
|
|
1652
|
+
self.dbPath if self.tdenv.detail > 1 else "local db",
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
station.dbname = "DELETED " + station.dbname
|
|
1656
|
+
del station
|
|
1657
|
+
|
|
1658
|
+
def lookupPlace(self, name):
|
|
1659
|
+
"""
|
|
1660
|
+
Lookup the station/system specified by 'name' which can be the
|
|
1661
|
+
name of a System or Station or it can be "System/Station" when
|
|
1662
|
+
the user needs to disambiguate a station. In this case, both
|
|
1663
|
+
system and station can be partial matches.
|
|
1664
|
+
|
|
1665
|
+
The system tries to allow partial matches as well as matches
|
|
1666
|
+
which omit whitespaces. In order to do this and still support
|
|
1667
|
+
the massive namespace of Stars and Systems, we rank the
|
|
1668
|
+
matches so that exact matches win, and only inferior close
|
|
1669
|
+
matches are looked at if no exacts are found.
|
|
1670
|
+
|
|
1671
|
+
Legal annotations:
|
|
1672
|
+
system
|
|
1673
|
+
station
|
|
1674
|
+
@system [explicitly a system name]
|
|
1675
|
+
/station [explicitly a station name]
|
|
1676
|
+
system/station
|
|
1677
|
+
@system/station
|
|
1678
|
+
"""
|
|
1679
|
+
# Pass-through for already-resolved objects
|
|
1680
|
+
if isinstance(name, (System, Station)):
|
|
1681
|
+
return name
|
|
1682
|
+
|
|
1683
|
+
if not isinstance(name, str):
|
|
1684
|
+
raise TypeError(
|
|
1685
|
+
f"lookupPlace expects str/System/Station, got {type(name)!r}"
|
|
1686
|
+
)
|
|
1687
|
+
|
|
1688
|
+
# ------------------------------------------------------------------
|
|
1689
|
+
# Fast path: queries that look like "just a system name"
|
|
1690
|
+
#
|
|
1691
|
+
# This path is where the new name-collision behaviour lives so that
|
|
1692
|
+
# lookupPlace honours:
|
|
1693
|
+
# - multiple systems with the same name, and
|
|
1694
|
+
# - the "@N" index notation (e.g. "Lorionis-SOC 13@2").
|
|
1695
|
+
#
|
|
1696
|
+
# We exclude:
|
|
1697
|
+
# - leading "/" (explicit station)
|
|
1698
|
+
# - any "/" or "\\" inside the string (system/station combos)
|
|
1699
|
+
# ------------------------------------------------------------------
|
|
1700
|
+
if not name.startswith("/") and "/" not in name and "\\" not in name:
|
|
1701
|
+
sys_key = name[1:] if name.startswith("@") else name
|
|
1702
|
+
# lookupSystem can throw various TradeExceptions, which we will forward
|
|
1703
|
+
# or it can throw a LookupError which we want to discard, it just means
|
|
1704
|
+
# that this lookup isn't ready yet.
|
|
1705
|
+
try:
|
|
1706
|
+
return self.lookupSystem(sys_key)
|
|
1707
|
+
except LookupError:
|
|
1708
|
+
# Not a system (or no reasonable system match) – fall back to
|
|
1709
|
+
# the generic place logic below to search stations as well.
|
|
1710
|
+
pass
|
|
1711
|
+
|
|
1712
|
+
# ------------------------------------------------------------------
|
|
1713
|
+
# Legacy combined system/station matching
|
|
1714
|
+
# ------------------------------------------------------------------
|
|
1715
|
+
|
|
1716
|
+
# Determine whether the user specified a system, a station, or both.
|
|
1717
|
+
slash_pos = name.find("/")
|
|
1718
|
+
if slash_pos < 0:
|
|
1719
|
+
slash_pos = name.find("\\") # support old "sys\stn" syntax too
|
|
1720
|
+
|
|
1721
|
+
# Leading '@' indicates "this is a system name"
|
|
1722
|
+
name_off = 1 if name.startswith("@") else 0
|
|
1723
|
+
|
|
1724
|
+
if slash_pos > name_off:
|
|
1725
|
+
# "sys/station" or "@sys/station"
|
|
1726
|
+
sys_name = name[name_off:slash_pos].upper()
|
|
1727
|
+
stn_name = name[slash_pos + 1:]
|
|
1728
|
+
elif slash_pos == name_off:
|
|
1729
|
+
# "/station" — explicit station, no system
|
|
1730
|
+
sys_name, stn_name = None, name[name_off + 1:]
|
|
1731
|
+
elif name_off:
|
|
1732
|
+
# "@system" — explicit system, no station
|
|
1733
|
+
sys_name, stn_name = name[name_off:].upper(), None
|
|
1734
|
+
else:
|
|
1735
|
+
# Bare name: treat as both potential system and station.
|
|
1736
|
+
stn_name = name
|
|
1737
|
+
sys_name = stn_name.upper()
|
|
1738
|
+
|
|
1739
|
+
exact_match = []
|
|
1740
|
+
close_match = []
|
|
1741
|
+
word_match = []
|
|
1742
|
+
any_match = []
|
|
1743
|
+
|
|
1744
|
+
def _lookup(token, candidates):
|
|
1745
|
+
"""Populate the match lists for the given search token."""
|
|
1746
|
+
norm_trans = TradeDB.normalizeTrans
|
|
1747
|
+
trim_trans = TradeDB.trimTrans
|
|
1748
|
+
|
|
1749
|
+
token_norm = token.translate(norm_trans)
|
|
1750
|
+
token_trim = token_norm.translate(trim_trans)
|
|
1751
|
+
|
|
1752
|
+
token_len = len(token)
|
|
1753
|
+
token_norm_len = len(token_norm)
|
|
1754
|
+
token_trim_len = len(token_trim)
|
|
1755
|
+
|
|
1756
|
+
for place in candidates:
|
|
1757
|
+
place_name = place.dbname
|
|
1758
|
+
place_norm = place_name.translate(norm_trans)
|
|
1759
|
+
place_norm_len = len(place_norm)
|
|
1760
|
+
|
|
1761
|
+
# If the trimmed needle is longer than the target, it can't match
|
|
1762
|
+
if token_trim_len > place_norm_len:
|
|
1763
|
+
continue
|
|
1764
|
+
|
|
1765
|
+
# 1) Exact name + normalization match
|
|
1766
|
+
if len(place_name) == token_len and place_norm == token_norm:
|
|
1767
|
+
exact_match.append(place)
|
|
1768
|
+
continue
|
|
1769
|
+
|
|
1770
|
+
# 2) Same normalized length and contents -> "close" match
|
|
1771
|
+
if place_norm_len == token_norm_len and place_norm == token_norm:
|
|
1772
|
+
close_match.append(place)
|
|
1773
|
+
continue
|
|
1774
|
+
|
|
1775
|
+
# 3) Substring of the normalized name, with word-boundary checks
|
|
1776
|
+
if token_norm_len < place_norm_len:
|
|
1777
|
+
pos = place_norm.find(token_norm)
|
|
1778
|
+
if pos == 0:
|
|
1779
|
+
# At the start of the name
|
|
1780
|
+
if place_norm[token_norm_len:token_norm_len + 1] == " ":
|
|
1781
|
+
word_match.append(place)
|
|
1782
|
+
else:
|
|
1783
|
+
any_match.append(place)
|
|
1784
|
+
continue
|
|
1785
|
+
|
|
1786
|
+
if pos > 0:
|
|
1787
|
+
before = place_norm[pos - 1:pos]
|
|
1788
|
+
after = place_norm[pos + token_norm_len:pos + token_norm_len + 1]
|
|
1789
|
+
if before == " " and after == " ":
|
|
1790
|
+
word_match.append(place)
|
|
1791
|
+
else:
|
|
1792
|
+
any_match.append(place)
|
|
1793
|
+
continue
|
|
1794
|
+
|
|
1795
|
+
# 4) Compare with whitespace and punctuation stripped
|
|
1796
|
+
place_trim = place_norm.translate(trim_trans)
|
|
1797
|
+
place_trim_len = len(place_trim)
|
|
1798
|
+
if place_trim_len == place_norm_len:
|
|
1799
|
+
# Normalization didn't change anything; nothing new to learn
|
|
1800
|
+
continue
|
|
1801
|
+
|
|
1802
|
+
# A fully-trimmed exact match is still "close"
|
|
1803
|
+
if place_trim_len == token_trim_len and place_trim == token_trim:
|
|
1804
|
+
close_match.append(place)
|
|
1805
|
+
continue
|
|
1806
|
+
|
|
1807
|
+
# Otherwise, any occurrence inside the trimmed name is "any"
|
|
1808
|
+
if token_trim and place_trim.find(token_trim) >= 0:
|
|
1809
|
+
any_match.append(place)
|
|
1810
|
+
|
|
1811
|
+
# First, resolve the system side if we have one.
|
|
1812
|
+
if sys_name:
|
|
1813
|
+
systems_bucket = self.systemByName.get(sys_name)
|
|
1814
|
+
if systems_bucket:
|
|
1815
|
+
# In older caches, systemByName held a single System; in the
|
|
1816
|
+
# new collision-aware form it holds a list[System].
|
|
1817
|
+
if isinstance(systems_bucket, System):
|
|
1818
|
+
exact_match.append(systems_bucket)
|
|
1819
|
+
else:
|
|
1820
|
+
# Assume it's an iterable of System instances.
|
|
1821
|
+
exact_match.extend(systems_bucket)
|
|
1822
|
+
else:
|
|
1823
|
+
_lookup(sys_name, self.systemByID.values())
|
|
1824
|
+
|
|
1825
|
+
# Now resolve the station side, if requested.
|
|
1826
|
+
if stn_name:
|
|
1827
|
+
# If both system and station were provided (sys/station form), we
|
|
1828
|
+
# try to narrow the station search to the systems we just matched.
|
|
1829
|
+
if slash_pos > name_off + 1 and (exact_match or close_match or word_match or any_match):
|
|
1830
|
+
station_candidates = []
|
|
1831
|
+
for system in itertools.chain(
|
|
1832
|
+
exact_match, close_match, word_match, any_match
|
|
1833
|
+
):
|
|
1834
|
+
station_candidates.extend(system.stations)
|
|
1835
|
+
|
|
1836
|
+
# Reset the match tiers; from here on they refer to stations.
|
|
1837
|
+
exact_match = []
|
|
1838
|
+
close_match = []
|
|
1839
|
+
word_match = []
|
|
1840
|
+
any_match = []
|
|
1841
|
+
else:
|
|
1842
|
+
# No usable system context: search all stations.
|
|
1843
|
+
station_candidates = self.stationByID.values()
|
|
1844
|
+
|
|
1845
|
+
_lookup(stn_name, station_candidates)
|
|
1846
|
+
|
|
1847
|
+
# Consult the match tiers in order; any single-element tier is a winner.
|
|
1848
|
+
for tier in (exact_match, close_match, word_match, any_match):
|
|
1849
|
+
if len(tier) == 1:
|
|
1850
|
+
return tier[0]
|
|
1851
|
+
|
|
1852
|
+
# No matches at all
|
|
1853
|
+
if not (exact_match or close_match or word_match or any_match):
|
|
1854
|
+
# NOTE: Historically this was a TradeException; it was changed to
|
|
1855
|
+
# LookupError so callers can distinguish "nothing matched" from
|
|
1856
|
+
# "ambiguous".
|
|
1857
|
+
raise LookupError(f"Unrecognized place: {name}")
|
|
1858
|
+
|
|
1859
|
+
# Multiple matches – ambiguous. For mixed system/station cases we keep
|
|
1860
|
+
# the original "System/Station" label; pure system ambiguities should
|
|
1861
|
+
# already have been caught by lookupSystem above.
|
|
1862
|
+
raise AmbiguityError(
|
|
1863
|
+
"System/Station",
|
|
1864
|
+
name,
|
|
1865
|
+
exact_match + close_match + word_match + any_match,
|
|
1866
|
+
key=lambda place: place.name(),
|
|
1867
|
+
)
|
|
1868
|
+
|
|
1869
|
+
def lookupStation(self, name, system=None):
|
|
1870
|
+
"""
|
|
1871
|
+
Look up a Station object by it's name or system.
|
|
1872
|
+
"""
|
|
1873
|
+
if isinstance(name, Station):
|
|
1874
|
+
return name
|
|
1875
|
+
if isinstance(name, System):
|
|
1876
|
+
# When given a system with only one station, return the station.
|
|
1877
|
+
if len(name.stations) != 1:
|
|
1878
|
+
raise SystemNotStationError(f"System '{name}' has {len(name.stations)} stations, please specify a station instead.")
|
|
1879
|
+
return name.stations[0]
|
|
1880
|
+
|
|
1881
|
+
if system:
|
|
1882
|
+
system = self.lookupSystem(system)
|
|
1883
|
+
return TradeDB.listSearch(
|
|
1884
|
+
"Station", name, system.stations,
|
|
1885
|
+
key=lambda system: system.dbname)
|
|
1886
|
+
|
|
1887
|
+
station, system = None, None
|
|
1888
|
+
try:
|
|
1889
|
+
system = TradeDB.listSearch(
|
|
1890
|
+
"System", name, self.systemByID.values(),
|
|
1891
|
+
key=lambda system: system.dbname
|
|
1892
|
+
)
|
|
1893
|
+
except LookupError:
|
|
1894
|
+
pass
|
|
1895
|
+
try:
|
|
1896
|
+
station = TradeDB.listSearch(
|
|
1897
|
+
"Station", name, self.stationByID.values(),
|
|
1898
|
+
key=lambda station: station.dbname
|
|
1899
|
+
)
|
|
1900
|
+
except LookupError:
|
|
1901
|
+
pass
|
|
1902
|
+
# If neither matched, we have a lookup error.
|
|
1903
|
+
if not (station or system):
|
|
1904
|
+
raise LookupError(f"'{name}' did not match any station or system.")
|
|
1905
|
+
|
|
1906
|
+
# If we matched both a station and a system, make sure they resovle to
|
|
1907
|
+
# the same station otherwise we have an ambiguity. Some stations have
|
|
1908
|
+
# the same name as their star system (Aulin/Aulin Enterprise)
|
|
1909
|
+
if system and station and system != station.system:
|
|
1910
|
+
raise AmbiguityError(
|
|
1911
|
+
'Station', name, [system.name(), station.name()]
|
|
1912
|
+
)
|
|
1913
|
+
|
|
1914
|
+
if station:
|
|
1915
|
+
return station
|
|
1916
|
+
|
|
1917
|
+
# If we only matched a system name, ensure that it's a single station
|
|
1918
|
+
# system otherwise they need to specify a station name.
|
|
1919
|
+
if len(system.stations) != 1:
|
|
1920
|
+
raise SystemNotStationError(
|
|
1921
|
+
f"System '{system.name()}' has {len(system.stations)} stations, please specify a station instead."
|
|
1922
|
+
)
|
|
1923
|
+
return system.stations[0]
|
|
1924
|
+
|
|
1925
|
+
def getDestinations(
|
|
1926
|
+
self,
|
|
1927
|
+
origin,
|
|
1928
|
+
maxJumps=None,
|
|
1929
|
+
maxLyPer=None,
|
|
1930
|
+
avoidPlaces=None,
|
|
1931
|
+
maxPadSize=None,
|
|
1932
|
+
maxLsFromStar=0,
|
|
1933
|
+
noPlanet=False,
|
|
1934
|
+
planetary=None,
|
|
1935
|
+
fleet=None,
|
|
1936
|
+
odyssey=None,
|
|
1937
|
+
):
|
|
1938
|
+
"""
|
|
1939
|
+
Gets a list of the Station destinations that can be reached
|
|
1940
|
+
from this Station within the specified constraints.
|
|
1941
|
+
Limits to stations we are trading with if trading is True.
|
|
1942
|
+
"""
|
|
1943
|
+
|
|
1944
|
+
if maxJumps is None:
|
|
1945
|
+
maxJumps = sys.maxsize
|
|
1946
|
+
maxLyPer = maxLyPer or self.tdenv.maxSystemLinkLy
|
|
1947
|
+
if avoidPlaces is None:
|
|
1948
|
+
avoidPlaces = ()
|
|
1949
|
+
|
|
1950
|
+
# The open list is the list of nodes we should consider next for
|
|
1951
|
+
# potential destinations.
|
|
1952
|
+
# The path list is a list of the destinations we've found and the
|
|
1953
|
+
# shortest path to them. It doubles as the "closed list".
|
|
1954
|
+
# The closed list is the list of nodes we've already been to (so
|
|
1955
|
+
# that we don't create loops A->B->C->A->B->C->...)
|
|
1956
|
+
|
|
1957
|
+
origSys = origin.system if isinstance(origin, Station) else origin
|
|
1958
|
+
openList = [DestinationNode(origSys, [origSys], 0)]
|
|
1959
|
+
# I don't want to have to consult both the pathList
|
|
1960
|
+
# AND the avoid list every time I'm considering a
|
|
1961
|
+
# station, so copy the avoid list into the pathList
|
|
1962
|
+
# with a negative distance so I can ignore them again
|
|
1963
|
+
# when I scrape the pathList.
|
|
1964
|
+
# Don't copy stations because those only affect our
|
|
1965
|
+
# termination points, and not the systems we can
|
|
1966
|
+
# pass through en-route.
|
|
1967
|
+
pathList = {
|
|
1968
|
+
system.ID: DestinationNode(system, None, -1.0)
|
|
1969
|
+
for system in avoidPlaces
|
|
1970
|
+
if isinstance(system, System)
|
|
1971
|
+
}
|
|
1972
|
+
if origSys.ID not in pathList:
|
|
1973
|
+
pathList[origSys.ID] = openList[0]
|
|
1974
|
+
|
|
1975
|
+
# As long as the open list is not empty, keep iterating.
|
|
1976
|
+
jumps = 0
|
|
1977
|
+
while openList and jumps < maxJumps:
|
|
1978
|
+
# Expand the search domain by one jump; grab the list of
|
|
1979
|
+
# nodes that are this many hops out and then clear the list.
|
|
1980
|
+
ring, openList = openList, []
|
|
1981
|
+
# All of the destinations we are about to consider will
|
|
1982
|
+
# either be on the closed list or they will be +1 jump away.
|
|
1983
|
+
jumps += 1
|
|
1984
|
+
|
|
1985
|
+
ring.sort(key=lambda dn: dn.distLy)
|
|
1986
|
+
|
|
1987
|
+
for node in ring:
|
|
1988
|
+
for (destSys, destDist) in self.genSystemsInRange(
|
|
1989
|
+
node.system, maxLyPer, False
|
|
1990
|
+
):
|
|
1991
|
+
dist = node.distLy + destDist
|
|
1992
|
+
# If we already have a shorter path, do nothing
|
|
1993
|
+
try:
|
|
1994
|
+
prevDist = pathList[destSys.ID].distLy
|
|
1995
|
+
except KeyError:
|
|
1996
|
+
pass
|
|
1997
|
+
else:
|
|
1998
|
+
if dist >= prevDist:
|
|
1999
|
+
continue
|
|
2000
|
+
# Add to the path list
|
|
2001
|
+
destNode = DestinationNode(
|
|
2002
|
+
destSys, node.via + [destSys], dist
|
|
2003
|
+
)
|
|
2004
|
+
pathList[destSys.ID] = destNode
|
|
2005
|
+
# Add to the open list but also include node to the via
|
|
2006
|
+
# list so that it serves as the via list for all next-hops.
|
|
2007
|
+
openList.append(destNode)
|
|
2008
|
+
|
|
2009
|
+
# We have a system-to-system path list, now we
|
|
2010
|
+
# need stations to terminate at.
|
|
2011
|
+
def path_iter_fn():
|
|
2012
|
+
for node in pathList.values():
|
|
2013
|
+
if node.distLy >= 0.0:
|
|
2014
|
+
for station in node.system.stations:
|
|
2015
|
+
yield node, station
|
|
2016
|
+
|
|
2017
|
+
fleet = fleet or "YN?"
|
|
2018
|
+
maxPadSize = maxPadSize or "SML?"
|
|
2019
|
+
odyssey = odyssey or "YN?"
|
|
2020
|
+
planetary = "N" if noPlanet else (planetary or "YN?")
|
|
2021
|
+
|
|
2022
|
+
path_iter = iter(
|
|
2023
|
+
(node, station) for (node, station) in path_iter_fn()
|
|
2024
|
+
if station.planetary in planetary and
|
|
2025
|
+
station not in avoidPlaces and
|
|
2026
|
+
station.maxPadSize in maxPadSize and
|
|
2027
|
+
station.fleet in fleet and
|
|
2028
|
+
station.odyssey in odyssey and
|
|
2029
|
+
(not maxLsFromStar or 0 < station.lsFromStar <= maxLsFromStar)
|
|
2030
|
+
)
|
|
2031
|
+
yield from (
|
|
2032
|
+
Destination(node.system, stn, node.via, node.distLy)
|
|
2033
|
+
for node, stn in path_iter
|
|
2034
|
+
)
|
|
2035
|
+
|
|
2036
|
+
############################################################
|
|
2037
|
+
# Ship data.
|
|
2038
|
+
|
|
2039
|
+
@lru_cache
|
|
2040
|
+
def lookupShip(self, name):
|
|
2041
|
+
""" Look up a ship by name. """
|
|
2042
|
+
stmt = select(SA_Ship.ship_id, SA_Ship.name, SA_Ship.cost) \
|
|
2043
|
+
.where(Ship.name == name)
|
|
2044
|
+
with self.Session() as session:
|
|
2045
|
+
try:
|
|
2046
|
+
row = session.execute(stmt).scalar_one()
|
|
2047
|
+
return Ship(row.ship_id, row.name, row.cost, stations=[])
|
|
2048
|
+
except NoResultFound:
|
|
2049
|
+
raise LookupError(f"Error: '{name}' doesn't match any Ship") from None
|
|
2050
|
+
|
|
2051
|
+
############################################################
|
|
2052
|
+
# Item data.
|
|
2053
|
+
|
|
2054
|
+
# TODO: Defer to SA_Category directly; requires migrating
|
|
2055
|
+
# all item references to the SA_Item table too (since then
|
|
2056
|
+
# the database relationship handles inheritance anyway)
|
|
2057
|
+
def categories(self):
|
|
2058
|
+
"""
|
|
2059
|
+
Iterate through the list of categories.
|
|
2060
|
+
key = category name, value = list of items.
|
|
2061
|
+
"""
|
|
2062
|
+
yield from self.categoryByID.items()
|
|
2063
|
+
|
|
2064
|
+
def _loadCategories(self):
|
|
2065
|
+
"""
|
|
2066
|
+
Populate the list of item categories using SQLAlchemy.
|
|
2067
|
+
CAUTION: Will orphan previously loaded objects.
|
|
2068
|
+
"""
|
|
2069
|
+
with self.Session() as session:
|
|
2070
|
+
rows = session.query(
|
|
2071
|
+
SA_Category.category_id,
|
|
2072
|
+
SA_Category.name,
|
|
2073
|
+
)
|
|
2074
|
+
self.categoryByID = {
|
|
2075
|
+
row.category_id: Category(row.category_id, row.name, [])
|
|
2076
|
+
for row in rows
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
self.tdenv.DEBUG1("Loaded {} Categories", len(self.categoryByID))
|
|
2080
|
+
|
|
2081
|
+
def lookupCategory(self, name):
|
|
2082
|
+
"""
|
|
2083
|
+
Look up a category by name
|
|
2084
|
+
"""
|
|
2085
|
+
return TradeDB.listSearch(
|
|
2086
|
+
"Category", name,
|
|
2087
|
+
self.categoryByID.values(),
|
|
2088
|
+
key=lambda cat: cat.dbname
|
|
2089
|
+
)
|
|
2090
|
+
|
|
2091
|
+
# TODO: Defer to SA_Item directly.
|
|
2092
|
+
def items(self):
|
|
2093
|
+
""" Iterate through the list of items. """
|
|
2094
|
+
yield from self.itemByID.values()
|
|
2095
|
+
|
|
2096
|
+
def _loadItems(self):
|
|
2097
|
+
"""
|
|
2098
|
+
Populate the Item list using SQLAlchemy.
|
|
2099
|
+
CAUTION: Will orphan previously loaded objects.
|
|
2100
|
+
"""
|
|
2101
|
+
itemByID, itemByName, itemByFDevID = {}, {}, {}
|
|
2102
|
+
with self.Session() as session:
|
|
2103
|
+
rows = session.query(
|
|
2104
|
+
SA_Item.item_id,
|
|
2105
|
+
SA_Item.name,
|
|
2106
|
+
SA_Item.category_id,
|
|
2107
|
+
SA_Item.avg_price,
|
|
2108
|
+
SA_Item.fdev_id,
|
|
2109
|
+
)
|
|
2110
|
+
for ID, name, categoryID, avgPrice, fdevID in rows:
|
|
2111
|
+
category = self.categoryByID[categoryID]
|
|
2112
|
+
item = Item(
|
|
2113
|
+
ID, name, category,
|
|
2114
|
+
f"{category.dbname}/{name}",
|
|
2115
|
+
avgPrice, fdevID
|
|
2116
|
+
)
|
|
2117
|
+
itemByID[ID] = item
|
|
2118
|
+
itemByName[name] = item
|
|
2119
|
+
if fdevID:
|
|
2120
|
+
itemByFDevID[fdevID] = item
|
|
2121
|
+
category.items.append(item)
|
|
2122
|
+
|
|
2123
|
+
self.itemByID = itemByID
|
|
2124
|
+
self.itemByName = itemByName
|
|
2125
|
+
self.itemByFDevID = itemByFDevID
|
|
2126
|
+
|
|
2127
|
+
self.tdenv.DEBUG1("Loaded {:n} Items", len(self.itemByID))
|
|
2128
|
+
|
|
2129
|
+
def lookupItem(self, name):
|
|
2130
|
+
"""
|
|
2131
|
+
Look up an Item by name using "CATEGORY/Item"
|
|
2132
|
+
"""
|
|
2133
|
+
return TradeDB.listSearch(
|
|
2134
|
+
"Item", name, self.itemByName.items(),
|
|
2135
|
+
key=lambda kvTup: kvTup[0],
|
|
2136
|
+
val=lambda kvTup: kvTup[1]
|
|
2137
|
+
)
|
|
2138
|
+
|
|
2139
|
+
def getAverageSelling(self):
|
|
2140
|
+
"""
|
|
2141
|
+
Query the database for average selling prices of all items using SQLAlchemy.
|
|
2142
|
+
"""
|
|
2143
|
+
if not self.avgSelling:
|
|
2144
|
+
self.avgSelling = dict.fromkeys(self.itemByID, 0)
|
|
2145
|
+
|
|
2146
|
+
with self.Session() as session:
|
|
2147
|
+
rows = (
|
|
2148
|
+
session.query(
|
|
2149
|
+
SA_Item.item_id,
|
|
2150
|
+
func.ifnull(func.avg(SA_StationItem.supply_price), 0),
|
|
2151
|
+
)
|
|
2152
|
+
.outerjoin(
|
|
2153
|
+
SA_StationItem,
|
|
2154
|
+
(SA_Item.item_id == SA_StationItem.item_id) &
|
|
2155
|
+
(SA_StationItem.supply_price > 0),
|
|
2156
|
+
)
|
|
2157
|
+
.filter(SA_StationItem.supply_price > 0)
|
|
2158
|
+
.group_by(SA_Item.item_id)
|
|
2159
|
+
)
|
|
2160
|
+
for ID, cr in rows:
|
|
2161
|
+
self.avgSelling[ID] = int(cr)
|
|
2162
|
+
|
|
2163
|
+
return self.avgSelling
|
|
2164
|
+
|
|
2165
|
+
def getAverageBuying(self):
|
|
2166
|
+
"""
|
|
2167
|
+
Query the database for average buying prices of all items using SQLAlchemy.
|
|
2168
|
+
"""
|
|
2169
|
+
if not self.avgBuying:
|
|
2170
|
+
self.avgBuying = dict.fromkeys(self.itemByID, 0)
|
|
2171
|
+
|
|
2172
|
+
with self.Session() as session:
|
|
2173
|
+
rows = (
|
|
2174
|
+
session.query(
|
|
2175
|
+
SA_Item.item_id,
|
|
2176
|
+
func.ifnull(func.avg(SA_StationItem.demand_price), 0),
|
|
2177
|
+
)
|
|
2178
|
+
.outerjoin(
|
|
2179
|
+
SA_StationItem,
|
|
2180
|
+
(SA_Item.item_id == SA_StationItem.item_id) &
|
|
2181
|
+
(SA_StationItem.demand_price > 0),
|
|
2182
|
+
)
|
|
2183
|
+
.filter(SA_StationItem.demand_price > 0)
|
|
2184
|
+
.group_by(SA_Item.item_id)
|
|
2185
|
+
)
|
|
2186
|
+
for ID, cr in rows:
|
|
2187
|
+
self.avgBuying[ID] = int(cr)
|
|
2188
|
+
|
|
2189
|
+
return self.avgBuying
|
|
2190
|
+
|
|
2191
|
+
############################################################
|
|
2192
|
+
# Price data.
|
|
2193
|
+
#
|
|
2194
|
+
def close(self, *, final: bool = False) -> None:
|
|
2195
|
+
if self.Session and final:
|
|
2196
|
+
del self.Session
|
|
2197
|
+
if self.engine:
|
|
2198
|
+
self.engine.dispose()
|
|
2199
|
+
if final:
|
|
2200
|
+
del self.engine
|
|
2201
|
+
self.engine = None
|
|
2202
|
+
# Keep engine + Session references so reloadCache/buildCache can reuse them
|
|
2203
|
+
|
|
2204
|
+
def load(self) -> None:
|
|
2205
|
+
"""
|
|
2206
|
+
Populate/re-populate this instance of TradeDB with data.
|
|
2207
|
+
WARNING: This will orphan existing records you have
|
|
2208
|
+
taken references to:
|
|
2209
|
+
tdb.load()
|
|
2210
|
+
x = tdb.lookupPlace("Aulin")
|
|
2211
|
+
tdb.load() # x now points to an orphan Aulin
|
|
2212
|
+
"""
|
|
2213
|
+
|
|
2214
|
+
self.tdenv.DEBUG1("Loading data")
|
|
2215
|
+
|
|
2216
|
+
started = time.time()
|
|
2217
|
+
self._loadSystems()
|
|
2218
|
+
self._loadStations()
|
|
2219
|
+
self._loadCategories()
|
|
2220
|
+
self._loadItems()
|
|
2221
|
+
self.tdenv.DEBUG0("Data load took {:.3f}s", time.time() - started)
|
|
2222
|
+
|
|
2223
|
+
@property
|
|
2224
|
+
def max_link_ly(self) -> float | int:
|
|
2225
|
+
return self.tdenv.maxSystemLinkLy
|
|
2226
|
+
|
|
2227
|
+
############################################################
|
|
2228
|
+
# General purpose static methods.
|
|
2229
|
+
#
|
|
2230
|
+
@staticmethod
|
|
2231
|
+
def listSearch(
|
|
2232
|
+
listType, lookup, values,
|
|
2233
|
+
key=lambda item: item,
|
|
2234
|
+
val=lambda item: item
|
|
2235
|
+
):
|
|
2236
|
+
"""
|
|
2237
|
+
Searches [values] for 'lookup' for least-ambiguous matches,
|
|
2238
|
+
return the matching value as stored in [values].
|
|
2239
|
+
|
|
2240
|
+
GIVEN [values] contains "bread", "water", "biscuits and "It",
|
|
2241
|
+
searching "ea" will return "bread", "WaT" will return "water"
|
|
2242
|
+
and "i" will return "biscuits".
|
|
2243
|
+
|
|
2244
|
+
Searching for "a" would raise an AmbiguityError because "a" matches
|
|
2245
|
+
"bread" and "water", but searching for "it" will return "It"
|
|
2246
|
+
because it provides an exact match of a key.
|
|
2247
|
+
"""
|
|
2248
|
+
ListSearchMatch = namedtuple('Match', ['key', 'value'])
|
|
2249
|
+
|
|
2250
|
+
normTrans = TradeDB.normalizeTrans
|
|
2251
|
+
trimTrans = TradeDB.trimTrans
|
|
2252
|
+
needle = lookup.translate(normTrans).translate(trimTrans)
|
|
2253
|
+
partialMatch, wordMatch = [], []
|
|
2254
|
+
# make a regex to match whole words
|
|
2255
|
+
wordRe = re.compile(f"\\b{lookup}\\b", re.IGNORECASE)
|
|
2256
|
+
# describe a match
|
|
2257
|
+
for entry in values:
|
|
2258
|
+
entryKey = key(entry)
|
|
2259
|
+
normVal = entryKey.translate(normTrans).translate(trimTrans)
|
|
2260
|
+
if normVal.find(needle) > -1:
|
|
2261
|
+
# If this is an exact match, ignore ambiguities.
|
|
2262
|
+
if len(normVal) == len(needle):
|
|
2263
|
+
return val(entry)
|
|
2264
|
+
match = ListSearchMatch(entryKey, val(entry))
|
|
2265
|
+
if wordRe.match(entryKey):
|
|
2266
|
+
wordMatch.append(match)
|
|
2267
|
+
else:
|
|
2268
|
+
partialMatch.append(match)
|
|
2269
|
+
# Whole word matches trump partial matches
|
|
2270
|
+
if wordMatch:
|
|
2271
|
+
if len(wordMatch) > 1:
|
|
2272
|
+
raise AmbiguityError(
|
|
2273
|
+
listType, lookup, wordMatch,
|
|
2274
|
+
key=lambda item: item.key,
|
|
2275
|
+
)
|
|
2276
|
+
return wordMatch[0].value
|
|
2277
|
+
# Fuzzy matches
|
|
2278
|
+
if partialMatch:
|
|
2279
|
+
if len(partialMatch) > 1:
|
|
2280
|
+
raise AmbiguityError(
|
|
2281
|
+
listType, lookup, partialMatch,
|
|
2282
|
+
key=lambda item: item.key,
|
|
2283
|
+
)
|
|
2284
|
+
return partialMatch[0].value
|
|
2285
|
+
# No matches
|
|
2286
|
+
raise LookupError(f"Error: '{lookup}' doesn't match any {listType}")
|
|
2287
|
+
|
|
2288
|
+
@staticmethod
|
|
2289
|
+
def normalizedStr(text: str) -> str:
|
|
2290
|
+
"""
|
|
2291
|
+
Returns a case folded, sanitized version of 'str' suitable for
|
|
2292
|
+
performing simple and partial matches against. Removes various
|
|
2293
|
+
punctuation characters that don't contribute to name uniqueness.
|
|
2294
|
+
NOTE: No-longer removes whitespaces or apostrophes.
|
|
2295
|
+
"""
|
|
2296
|
+
return text.translate(
|
|
2297
|
+
TradeDB.normalizeTrans
|
|
2298
|
+
).translate(
|
|
2299
|
+
TradeDB.trimTrans
|
|
2300
|
+
)
|
|
2301
|
+
|
|
2302
|
+
######################################################################
|
|
2303
|
+
# Assorted helpers
|
|
2304
|
+
|
|
2305
|
+
def describeAge(ageInSeconds: float | int) -> str:
|
|
2306
|
+
"""
|
|
2307
|
+
Turns an age (in seconds) into a text representation.
|
|
2308
|
+
"""
|
|
2309
|
+
hours = int(ageInSeconds / 3600)
|
|
2310
|
+
if hours < 1:
|
|
2311
|
+
return "<1 hr"
|
|
2312
|
+
if hours == 1:
|
|
2313
|
+
return "1 hr"
|
|
2314
|
+
if hours < 48:
|
|
2315
|
+
return f"{hours} hrs"
|
|
2316
|
+
days = int(hours / 24)
|
|
2317
|
+
if days < 90:
|
|
2318
|
+
return f"{days} days"
|
|
2319
|
+
|
|
2320
|
+
return f"{int(days / 31)} mths"
|