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.
Files changed (87) hide show
  1. py.typed +1 -0
  2. trade.py +49 -0
  3. tradedangerous/__init__.py +43 -0
  4. tradedangerous/cache.py +1381 -0
  5. tradedangerous/cli.py +136 -0
  6. tradedangerous/commands/TEMPLATE.py +74 -0
  7. tradedangerous/commands/__init__.py +244 -0
  8. tradedangerous/commands/buildcache_cmd.py +102 -0
  9. tradedangerous/commands/buy_cmd.py +427 -0
  10. tradedangerous/commands/commandenv.py +372 -0
  11. tradedangerous/commands/exceptions.py +94 -0
  12. tradedangerous/commands/export_cmd.py +150 -0
  13. tradedangerous/commands/import_cmd.py +222 -0
  14. tradedangerous/commands/local_cmd.py +243 -0
  15. tradedangerous/commands/market_cmd.py +207 -0
  16. tradedangerous/commands/nav_cmd.py +252 -0
  17. tradedangerous/commands/olddata_cmd.py +270 -0
  18. tradedangerous/commands/parsing.py +221 -0
  19. tradedangerous/commands/rares_cmd.py +298 -0
  20. tradedangerous/commands/run_cmd.py +1521 -0
  21. tradedangerous/commands/sell_cmd.py +262 -0
  22. tradedangerous/commands/shipvendor_cmd.py +60 -0
  23. tradedangerous/commands/station_cmd.py +68 -0
  24. tradedangerous/commands/trade_cmd.py +181 -0
  25. tradedangerous/commands/update_cmd.py +67 -0
  26. tradedangerous/corrections.py +55 -0
  27. tradedangerous/csvexport.py +234 -0
  28. tradedangerous/db/__init__.py +27 -0
  29. tradedangerous/db/adapter.py +192 -0
  30. tradedangerous/db/config.py +107 -0
  31. tradedangerous/db/engine.py +259 -0
  32. tradedangerous/db/lifecycle.py +332 -0
  33. tradedangerous/db/locks.py +208 -0
  34. tradedangerous/db/orm_models.py +500 -0
  35. tradedangerous/db/paths.py +113 -0
  36. tradedangerous/db/utils.py +661 -0
  37. tradedangerous/edscupdate.py +565 -0
  38. tradedangerous/edsmupdate.py +474 -0
  39. tradedangerous/formatting.py +210 -0
  40. tradedangerous/fs.py +156 -0
  41. tradedangerous/gui.py +1146 -0
  42. tradedangerous/mapping.py +133 -0
  43. tradedangerous/mfd/__init__.py +103 -0
  44. tradedangerous/mfd/saitek/__init__.py +3 -0
  45. tradedangerous/mfd/saitek/directoutput.py +678 -0
  46. tradedangerous/mfd/saitek/x52pro.py +195 -0
  47. tradedangerous/misc/checkpricebounds.py +287 -0
  48. tradedangerous/misc/clipboard.py +49 -0
  49. tradedangerous/misc/coord64.py +83 -0
  50. tradedangerous/misc/csvdialect.py +57 -0
  51. tradedangerous/misc/derp-sentinel.py +35 -0
  52. tradedangerous/misc/diff-system-csvs.py +159 -0
  53. tradedangerous/misc/eddb.py +81 -0
  54. tradedangerous/misc/eddn.py +349 -0
  55. tradedangerous/misc/edsc.py +437 -0
  56. tradedangerous/misc/edsm.py +121 -0
  57. tradedangerous/misc/importeddbstats.py +54 -0
  58. tradedangerous/misc/prices-json-exp.py +179 -0
  59. tradedangerous/misc/progress.py +194 -0
  60. tradedangerous/plugins/__init__.py +249 -0
  61. tradedangerous/plugins/edcd_plug.py +371 -0
  62. tradedangerous/plugins/eddblink_plug.py +861 -0
  63. tradedangerous/plugins/edmc_batch_plug.py +133 -0
  64. tradedangerous/plugins/spansh_plug.py +2647 -0
  65. tradedangerous/prices.py +211 -0
  66. tradedangerous/submit-distances.py +422 -0
  67. tradedangerous/templates/Added.csv +37 -0
  68. tradedangerous/templates/Category.csv +17 -0
  69. tradedangerous/templates/RareItem.csv +143 -0
  70. tradedangerous/templates/TradeDangerous.sql +338 -0
  71. tradedangerous/tools.py +40 -0
  72. tradedangerous/tradecalc.py +1302 -0
  73. tradedangerous/tradedb.py +2320 -0
  74. tradedangerous/tradeenv.py +313 -0
  75. tradedangerous/tradeenv.pyi +109 -0
  76. tradedangerous/tradeexcept.py +131 -0
  77. tradedangerous/tradeorm.py +183 -0
  78. tradedangerous/transfers.py +192 -0
  79. tradedangerous/utils.py +243 -0
  80. tradedangerous/version.py +16 -0
  81. tradedangerous-12.7.6.dist-info/METADATA +106 -0
  82. tradedangerous-12.7.6.dist-info/RECORD +87 -0
  83. tradedangerous-12.7.6.dist-info/WHEEL +5 -0
  84. tradedangerous-12.7.6.dist-info/entry_points.txt +3 -0
  85. tradedangerous-12.7.6.dist-info/licenses/LICENSE +373 -0
  86. tradedangerous-12.7.6.dist-info/top_level.txt +2 -0
  87. tradegui.py +24 -0
@@ -0,0 +1,1302 @@
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
+ # I guarantee there is at least one bug neither of us knew about.
10
+ # --------------------------------------------------------------------
11
+ # TradeDangerous :: Modules :: Profit Calculator
12
+ #
13
+ # This module has been refactored from legacy SQLite raw SQL access
14
+ # to use SQLAlchemy ORM sessions. It retains the same API surface
15
+ # expected by other modules (mimicking legacy behaviour), but
16
+ # now queries ORM models instead of sqlite3 cursors.
17
+
18
+ """
19
+ TradeCalc provides a class for calculating trade loads, hops or
20
+ routes, along with some amount of state.
21
+
22
+ The intent was for it to carry a larger amount of state but
23
+ much of that got moved into TradeEnv, so right now TradeCalc
24
+ looks a little odd.
25
+
26
+ Significant Functions:
27
+
28
+ Tradecalc.getBestHops
29
+ Finds the best "next hop"s given a set of routes.
30
+
31
+ Classes:
32
+
33
+ TradeCalc
34
+ Encapsulates the calculation functions and item-trades,
35
+
36
+ Route
37
+ Describes a sequence of trade hops.
38
+
39
+ TradeLoad
40
+ Describe a cargo load to be carried on a hop.
41
+ """
42
+
43
+ ######################################################################
44
+ # Imports
45
+
46
+ from __future__ import annotations
47
+
48
+ from collections import defaultdict
49
+ from typing import NamedTuple
50
+ import locale
51
+ import os
52
+ import re
53
+ import sys
54
+ import time
55
+ import typing
56
+
57
+ from sqlalchemy import text as _sa_text
58
+
59
+ from .tradedb import Item
60
+ from .tradeexcept import SimpleAbort, TradeException
61
+ # Legacy-style helpers (these remain expected by other modules)
62
+ from .tradedb import Trade, Destination, describeAge
63
+
64
+ # ORM models (SQLAlchemy)
65
+ from tradedangerous.db.utils import parse_ts # replaces legacy strftime('%s', modified)
66
+
67
+ if typing.TYPE_CHECKING:
68
+ from collections.abc import Callable, Iterable
69
+ from tradedangerous import TradeDB, TradeEnv
70
+
71
+ locale.setlocale(locale.LC_ALL, '')
72
+
73
+
74
+ ######################################################################
75
+ # Exceptions
76
+
77
+
78
+ class UserAbortedRun(SimpleAbort):
79
+ """
80
+ UserAbortedRunError is raised when a user hits ctrl-c during a
81
+ route calculation after useful work has been done that we may
82
+ still want to report to the user.
83
+
84
+ If there is no useful work, then we should probably allow the
85
+ ctrl-c to just fall thru.
86
+ """
87
+ def __str__(self) -> str:
88
+ return f"*** Ctrl+C: User aborted run: {super().__str__()}"
89
+
90
+
91
+ class BadTimestampError(TradeException):
92
+ """
93
+ Raised when a StationItem row has an invalid or unparsable timestamp.
94
+ """
95
+
96
+ def __init__(self, tdb, stationID, itemID, modified):
97
+ self.station = tdb.stationByID[stationID]
98
+ self.item = tdb.itemByID[itemID]
99
+ self.modified = modified
100
+
101
+ def __str__(self):
102
+ return (
103
+ "Error loading price data from the local db:\n"
104
+ f"{self.station.name()} has a StationItem entry for "
105
+ f"\"{self.item.name()}\" with an invalid modified timestamp: "
106
+ f"'{self.modified}'."
107
+ )
108
+
109
+
110
+ class NoHopsError(TradeException):
111
+ """Raised when no possible hops can be generated within constraints."""
112
+ pass
113
+
114
+
115
+ ######################################################################
116
+ # TradeLoad (namedtuple wrapper)
117
+
118
+
119
+ class TradeLoad(NamedTuple):
120
+ """
121
+ Describes the manifest of items to be exchanged in a trade.
122
+
123
+ Attributes:
124
+ items : list of (item, qty) tuples tracking the load
125
+ gainCr : predicted total gain in credits
126
+ costCr : how much this load was bought for
127
+ units : total number of units across all items
128
+ """
129
+ items: list[tuple[Item, int]]
130
+ gainCr: int
131
+ costCr: int
132
+ units: int
133
+
134
+ def __bool__(self):
135
+ return self.units > 0
136
+
137
+ def __lt__(self, rhs):
138
+ if self.gainCr < rhs.gainCr:
139
+ return True
140
+ if rhs.gainCr < self.gainCr:
141
+ return False
142
+ if self.units < rhs.units:
143
+ return True
144
+ if rhs.units < self.units:
145
+ return False
146
+ return self.costCr < rhs.costCr
147
+
148
+ @property
149
+ def gpt(self):
150
+ """Gain per ton (credits per unit)."""
151
+ return self.gainCr / self.units if self.units else 0
152
+
153
+
154
+ # A convenience empty load (used as sentinel in fitting algorithms).
155
+ emptyLoad = TradeLoad((), 0, 0, 0)
156
+
157
+ ######################################################################
158
+ # Classes
159
+
160
+
161
+ class Route:
162
+ """
163
+ Describes a series of hops where a TradeLoad is picked up at
164
+ one station, the player travels via 0 or more hyperspace
165
+ jumps and docks at a second station where they unload.
166
+
167
+ Example:
168
+ 10 Algae + 5 Hydrogen at Station A,
169
+ jump to System2, jump to System3,
170
+ dock at Station B, sell everything, buy gold,
171
+ jump to System4 and sell everything at Station X.
172
+ """
173
+
174
+ __slots__ = ("route", "hops", "startCr", "gainCr", "jumps", "score")
175
+
176
+ def __init__(self, stations, hops, startCr, gainCr, jumps, score):
177
+ assert stations
178
+ self.route = stations
179
+ self.hops = hops
180
+ self.startCr = startCr
181
+ self.gainCr = gainCr
182
+ self.jumps = jumps
183
+ self.score = score
184
+
185
+ @property
186
+ def firstStation(self):
187
+ return self.route[0]
188
+
189
+ @property
190
+ def firstSystem(self):
191
+ return self.route[0].system
192
+
193
+ @property
194
+ def lastStation(self):
195
+ return self.route[-1]
196
+
197
+ @property
198
+ def lastSystem(self):
199
+ return self.route[-1].system
200
+
201
+ @property
202
+ def avggpt(self):
203
+ if self.hops:
204
+ return sum(hop.gpt for hop in self.hops) // len(self.hops)
205
+ return 0
206
+
207
+ @property
208
+ def gpt(self):
209
+ if self.hops:
210
+ return (
211
+ sum(hop.gainCr for hop in self.hops)
212
+ // sum(hop.units for hop in self.hops)
213
+ )
214
+ return 0
215
+
216
+ def plus(self, dst, hop, jumps, score):
217
+ return Route(
218
+ self.route + (dst,),
219
+ self.hops + (hop,),
220
+ self.startCr,
221
+ self.gainCr + hop[1],
222
+ self.jumps + (jumps,),
223
+ self.score + score,
224
+ )
225
+
226
+ def __lt__(self, rhs):
227
+ if self.score == rhs.score:
228
+ return len(self.jumps) < len(rhs.jumps)
229
+ return self.score > rhs.score
230
+
231
+ def __eq__(self, rhs):
232
+ return self.score == rhs.score and len(self.jumps) == len(rhs.jumps)
233
+
234
+ def debug_text(self, colorize: Callable[[str, str], str]) -> str:
235
+ lhs = colorize("cyan", self.firstStation.name())
236
+ rhs = colorize("blue", self.lastStation.name())
237
+ return f"{lhs} (#{self.firstStation.ID}) -> {rhs} (#{self.lastStation.ID})"
238
+
239
+ def text(self, colorize: Callable[[str, str], str]) -> str:
240
+ lhs = colorize("cyan", self.firstStation.name())
241
+ rhs = colorize("blue", self.lastStation.name())
242
+ return f"{lhs} -> {rhs}"
243
+
244
+ def detail(self, tdenv):
245
+ """
246
+ Legacy helper used by run_cmd.render().
247
+ Renders this route using cmdenv/tdenv display settings.
248
+
249
+ Honors TD_NO_COLOR and tdenv.noColor to disable ANSI color codes.
250
+ """
251
+ # TD_NO_COLOR disables color if set to anything truthy (except 0/false/no/off/"")
252
+ env_val = os.getenv("TD_NO_COLOR", "")
253
+ env_no_color = bool(env_val) and env_val.strip().lower() not in ("0", "", "false", "no", "off")
254
+
255
+ no_color = env_no_color or bool(getattr(tdenv, "noColor", False))
256
+
257
+ if no_color:
258
+ def colorize(_c, s):
259
+ return s
260
+ else:
261
+ _cz = getattr(tdenv, "colorize", None)
262
+ if callable(_cz):
263
+ def colorize(c, s):
264
+ return _cz(c, s)
265
+ else:
266
+ def colorize(_c, s):
267
+ return s
268
+
269
+ detail = int(getattr(tdenv, "detail", 0) or 0)
270
+ goalSystem = getattr(tdenv, "goalSystem", None)
271
+ credits = int(getattr(tdenv, "credits", 0) or 0)
272
+
273
+ return self.render(colorize, tdenv, detail=detail, goalSystem=goalSystem, credits=credits)
274
+
275
+ def render(self, colorize, tdenv, detail=0, goalSystem=None, credits=0):
276
+ """
277
+ Produce a formatted string representation of this route.
278
+ """
279
+
280
+ def genSubValues():
281
+ for hop in self.hops:
282
+ for tr, _ in hop[0]:
283
+ yield len(tr.name(detail))
284
+
285
+ longestNameLen = max(genSubValues(), default=0)
286
+
287
+ text = self.text(colorize)
288
+ if detail >= 1:
289
+ text += f" (score: {self.score:f})"
290
+ text += "\n"
291
+
292
+ jumpsFmt = " Jump {jumps}\n"
293
+ cruiseFmt = " Supercruise to {stn}\n"
294
+ distFmt = None
295
+
296
+ if detail > 1:
297
+ if detail > 2:
298
+ text += self.summary() + "\n"
299
+ if tdenv.maxJumpsPer > 1:
300
+ distFmt = " Direct: {dist:0.2f}ly, Trip: {trav:0.2f}ly\n"
301
+
302
+ hopFmt = (
303
+ " Load from " + colorize("cyan", "{station}") + ":\n{purchases}"
304
+ )
305
+ hopStepFmt = (
306
+ colorize("lightYellow", " {qty:>4}")
307
+ + " x "
308
+ + colorize("yellow", "{item:<{longestName}} ")
309
+ + "{eacost:>8n}cr vs {easell:>8n}cr, "
310
+ "{age}"
311
+ )
312
+ if detail > 2:
313
+ hopStepFmt += ", total: {ttlcost:>10n}cr"
314
+ hopStepFmt += "\n"
315
+
316
+ if not tdenv.summary:
317
+ dockFmt = (
318
+ " Unload at "
319
+ + colorize("lightBlue", "{station}")
320
+ + " => Gain {gain:n}cr "
321
+ "({tongain:n}cr/ton) => {credits:n}cr\n"
322
+ )
323
+ else:
324
+ jumpsFmt = re.sub(" ", " ", jumpsFmt, re.M)
325
+ cruiseFmt = re.sub(" ", " ", cruiseFmt, re.M)
326
+ if distFmt:
327
+ distFmt = re.sub(" ", " ", distFmt, re.M)
328
+ hopFmt = "\n" + hopFmt
329
+ dockFmt = " Expect to gain {gain:n}cr ({tongain:n}cr/ton)\n"
330
+
331
+ footer = " " + "-" * 76 + "\n"
332
+ endFmt = (
333
+ "Finish at "
334
+ + colorize("blue", "{station} ")
335
+ + "gaining {gain:n}cr ({tongain:n}cr/ton) "
336
+ "=> est {credits:n}cr total\n"
337
+ )
338
+
339
+ elif detail:
340
+ hopFmt = " Load from " + colorize("cyan", "{station}") + ":{purchases}\n"
341
+ hopStepFmt = (
342
+ colorize("lightYellow", " {qty}")
343
+ + " x "
344
+ + colorize("yellow", "{item}")
345
+ + " (@{eacost}cr),"
346
+ )
347
+ footer = None
348
+ dockFmt = " Dock at " + colorize("lightBlue", "{station}\n")
349
+ endFmt = (
350
+ " Finish "
351
+ + colorize("blue", "{station} ")
352
+ + "+ {gain:n}cr ({tongain:n}cr/ton)"
353
+ "=> {credits:n}cr\n"
354
+ )
355
+
356
+ else:
357
+ hopFmt = colorize("cyan", " {station}:{purchases}\n")
358
+ hopStepFmt = (
359
+ colorize("lightYellow", " {qty}")
360
+ + " x "
361
+ + colorize("yellow", "{item}")
362
+ + ","
363
+ )
364
+ footer = None
365
+ dockFmt = None
366
+ endFmt = colorize("blue", " {station}") + " +{gain:n}cr ({tongain:n}/ton)"
367
+
368
+ def jumpList(jumps):
369
+ text, last = "", None
370
+ travelled = 0.0
371
+ for jump in jumps:
372
+ if last:
373
+ dist = last.distanceTo(jump)
374
+ if dist:
375
+ if tdenv.detail:
376
+ text += f", {dist:.2f}ly -> "
377
+ else:
378
+ text += " -> "
379
+ else:
380
+ text += " >>> "
381
+ travelled += dist
382
+ text += jump.name()
383
+ last = jump
384
+ return travelled, text
385
+
386
+ if detail > 1:
387
+
388
+ def decorateStation(station):
389
+ details = []
390
+ if station.lsFromStar:
391
+ details.append(station.distFromStar(True))
392
+ if station.blackMarket != "?":
393
+ details.append("BMk:" + station.blackMarket)
394
+ if station.maxPadSize != "?":
395
+ details.append("Pad:" + station.maxPadSize)
396
+ if station.planetary != "?":
397
+ details.append("Plt:" + station.planetary)
398
+ if station.fleet != "?":
399
+ details.append("Flc:" + station.fleet)
400
+ if station.odyssey != "?":
401
+ details.append("Ody:" + station.odyssey)
402
+ if station.shipyard != "?":
403
+ details.append("Shp:" + station.shipyard)
404
+ if station.outfitting != "?":
405
+ details.append("Out:" + station.outfitting)
406
+ if station.refuel != "?":
407
+ details.append("Ref:" + station.refuel)
408
+ details = "{} ({})".format(
409
+ station.name(), ", ".join(details or ["no details"])
410
+ )
411
+ return details
412
+
413
+ else:
414
+
415
+ def decorateStation(station):
416
+ return station.name()
417
+
418
+ if detail and goalSystem:
419
+
420
+ def goalDistance(station):
421
+ return (
422
+ f" [Distance to {goalSystem.name()}: "
423
+ f"{station.system.distanceTo(goalSystem):.2f} ly]\n"
424
+ )
425
+
426
+ else:
427
+
428
+ def goalDistance(station):
429
+ return ""
430
+
431
+ gainCr = 0
432
+ for i, hop in enumerate(self.hops):
433
+ hopGainCr, hopTonnes = hop[1], 0
434
+ purchases = ""
435
+ for (trade, qty) in sorted(
436
+ hop[0],
437
+ key=lambda tradeOpt: tradeOpt[1] * tradeOpt[0].gainCr,
438
+ reverse=True,
439
+ ):
440
+ if abs(trade.srcAge - trade.dstAge) <= (30 * 60):
441
+ age = max(trade.srcAge, trade.dstAge)
442
+ age = describeAge(age)
443
+ else:
444
+ srcAge = describeAge(trade.srcAge)
445
+ dstAge = describeAge(trade.dstAge)
446
+ age = f"{srcAge} vs {dstAge}"
447
+
448
+ purchases += hopStepFmt.format(
449
+ qty=qty,
450
+ item=trade.name(detail),
451
+ eacost=trade.costCr,
452
+ easell=trade.costCr + trade.gainCr,
453
+ ttlcost=trade.costCr * qty,
454
+ longestName=longestNameLen,
455
+ age=age,
456
+ )
457
+ hopTonnes += qty
458
+
459
+ text += goalDistance(self.route[i])
460
+ text += hopFmt.format(station=decorateStation(self.route[i]), purchases=purchases)
461
+
462
+ if tdenv.showJumps and jumpsFmt and self.jumps[i]:
463
+ startStn = self.route[i]
464
+ endStn = self.route[i + 1]
465
+ if startStn.system is not endStn.system:
466
+ fmt = jumpsFmt
467
+ travelled, jumps = jumpList(self.jumps[i])
468
+ else:
469
+ fmt = cruiseFmt
470
+ travelled, jumps = 0.0, f"{startStn.name()} >>> {endStn.name()}"
471
+
472
+ text += fmt.format(
473
+ jumps=jumps,
474
+ gain=hopGainCr,
475
+ tongain=hopGainCr / hopTonnes,
476
+ credits=credits + gainCr + hopGainCr,
477
+ stn=self.route[i + 1].dbname,
478
+ )
479
+
480
+ if travelled and distFmt and len(self.jumps[i]) > 2:
481
+ text += distFmt.format(
482
+ dist=startStn.system.distanceTo(endStn.system), trav=travelled
483
+ )
484
+
485
+ if dockFmt:
486
+ stn = self.route[i + 1]
487
+ text += dockFmt.format(
488
+ station=decorateStation(stn),
489
+ gain=hopGainCr,
490
+ tongain=hopGainCr / hopTonnes,
491
+ credits=credits + gainCr + hopGainCr,
492
+ )
493
+
494
+ gainCr += hopGainCr
495
+
496
+ lastStation = self.lastStation
497
+ if lastStation.system is not goalSystem:
498
+ text += goalDistance(lastStation)
499
+ text += footer or ""
500
+ text += endFmt.format(
501
+ station=decorateStation(lastStation),
502
+ gain=gainCr,
503
+ credits=credits + gainCr,
504
+ tongain=self.gpt,
505
+ )
506
+
507
+ return text
508
+
509
+ def summary(self):
510
+ credits, hops, jumps = self.startCr, self.hops, self.jumps
511
+ ttlGainCr = sum(hop[1] for hop in hops)
512
+ numJumps = sum(
513
+ len(hopJumps) - 1 for hopJumps in jumps if hopJumps
514
+ )
515
+ return (
516
+ "Start CR: {start:10n}\n"
517
+ "Hops : {hops:10n}\n"
518
+ "Jumps : {jumps:10n}\n"
519
+ "Gain CR : {gain:10n}\n"
520
+ "Gain/Hop: {hopgain:10n}\n"
521
+ "Final CR: {final:10n}\n".format(
522
+ start=credits,
523
+ hops=len(hops),
524
+ jumps=numJumps,
525
+ gain=ttlGainCr,
526
+ hopgain=ttlGainCr // len(hops),
527
+ final=credits + ttlGainCr,
528
+ )
529
+ )
530
+
531
+
532
+ def sigmoid(x: float | int) -> float:
533
+ # [eyeonus]:
534
+ # (Keep in mind all this ignores values of x<0.)
535
+ # The sigmoid: (1-(25(x-1))/(1+abs(25(x-1))))/4
536
+ # ranges between 0.5 and 0 with a drop around x=1,
537
+ # which makes it great for giving a boost to distances < 1Kls.
538
+ #
539
+ # The sigmoid: (-1-(50(x-4))/(1+abs(50(x-4))))/4
540
+ # ranges between 0 and -0.5 with a drop around x=4,
541
+ # making it great for penalizing distances > 4Kls.
542
+ #
543
+ # The curve: (-1+1/(x+1)^((x+1)/4))/2
544
+ # ranges between 0 and -0.5 in a smooth arc,
545
+ # which will be used for making distances
546
+ # closer to 4Kls get a slightly higher penalty
547
+ # then distances closer to 1Kls.
548
+ #
549
+ # Adding the three together creates a doubly-kinked curve
550
+ # that ranges from ~0.5 to -1.0, with drops around x=1 and x=4,
551
+ # which closely matches ksfone's intention without going into
552
+ # negative numbers and causing problems when we add it to
553
+ # the multiplier variable. ( 1 + -1 = 0 )
554
+ #
555
+ # You can see a graph of the formula here:
556
+ # https://goo.gl/sn1PqQ
557
+ # NOTE: The black curve is at a penalty of 0%,
558
+ # the red curve at a penalty of 100%, with intermediates at
559
+ # 25%, 50%, and 75%.
560
+ # The other colored lines show the penalty curves individually
561
+ # and the teal composite of all three.
562
+ return x / (1 + abs(x))
563
+
564
+ class TradeCalc:
565
+ """
566
+ Container for accessing trade calculations with common properties.
567
+ """
568
+
569
+ def __init__(
570
+ self,
571
+ tdb: TradeDB,
572
+ tdenv: TradeEnv | None = None,
573
+ fit: callable | None = None,
574
+ items: list[Item] | None = None,
575
+ restrict_station_ids: Iterable[int] = None
576
+ ):
577
+ """
578
+ Constructs the TradeCalc object and loads sell/buy data.
579
+ """
580
+
581
+ if not tdenv:
582
+ tdenv = tdb.tdenv
583
+ self.tdb = tdb
584
+ self.tdenv = tdenv
585
+ self.aborted: bool = False
586
+ self.defaultFit = fit or self.simpleFit
587
+ if "BRUTE_FIT" in os.environ:
588
+ self.defaultFit = self.bruteForceFit
589
+
590
+ self._restrict_station_ids = tuple(restrict_station_ids) if restrict_station_ids else None
591
+
592
+ minSupply = self.tdenv.supply or 0
593
+ minDemand = self.tdenv.demand or 0
594
+
595
+ # ---------- Build optional item filter (avoidItems + specific items) ----------
596
+ itemFilter = None
597
+ if tdenv.avoidItems or items:
598
+ avoidItemIDs = {item.ID for item in tdenv.avoidItems}
599
+ loadItems = items or tdb.itemByID.values()
600
+ loadIDs = []
601
+ for item in loadItems:
602
+ ID = item if isinstance(item, int) else item.ID
603
+ if ID not in avoidItemIDs:
604
+ loadIDs.append(ID)
605
+ if not loadIDs:
606
+ raise TradeException("No items to load.")
607
+ itemFilter = loadIDs
608
+
609
+ # ---------- Maps and counters ----------
610
+ demand = self.stationsBuying = defaultdict(list)
611
+ supply = self.stationsSelling = defaultdict(list)
612
+ dmdCount = supCount = 0
613
+ nowS = int(time.time())
614
+
615
+ # ---------- Progress heartbeat (only with --progress) ----------
616
+ showProgress = bool(getattr(tdenv, "progress", False))
617
+ hb_interval = 0.5
618
+ last_hb = 0.0
619
+ spinner = ("|", "/", "-", "\\")
620
+ spin_i = 0
621
+ rows_seen = 0
622
+
623
+ def heartbeat():
624
+ nonlocal last_hb, spin_i
625
+ now = time.time()
626
+ if (now - last_hb) < hb_interval:
627
+ return
628
+ last_hb = now
629
+ s = spinner[spin_i]
630
+ spin_i = (spin_i + 1) % len(spinner)
631
+ sys.stdout.write(
632
+ f"\r\x1b[2K{s} Scanning market data… rows {rows_seen:n} kept: buys {dmdCount:n}, sells {supCount:n}"
633
+ )
634
+ sys.stdout.flush()
635
+
636
+ # ---------- Core/Engine path (NO Session; NO ORM entities) ----------
637
+ columns = (
638
+ "station_id, item_id, "
639
+ "CASE WHEN demand_units >= :mindemand THEN demand_price ELSE 0 END AS fx_demand_price, demand_units, demand_level, "
640
+ "CASE WHEN supply_units >= :minsupply THEN supply_price ELSE 0 END AS fx_supply_price, supply_units, supply_level, "
641
+ "modified"
642
+ )
643
+
644
+ where_clauses = ["fx_demand_price > 0 OR fx_supply_price > 0"]
645
+ params = {"mindemand": minDemand or 1, "minsupply": minSupply or 1}
646
+
647
+ # Age cutoff (if provided in env)
648
+ if tdenv.maxAge:
649
+ cutoffS = nowS - (tdenv.maxAge * 60 * 60 * 24)
650
+ if tdb.engine.dialect.name == "sqlite":
651
+ where_clauses.append("CAST(strftime('%s', modified) AS INTEGER) >= :cutoffS")
652
+ else:
653
+ where_clauses.append("UNIX_TIMESTAMP(modified) >= :cutoffS")
654
+ params["cutoffS"] = cutoffS
655
+
656
+ # Optional item filter — enumerate placeholders (SQLAlchemy text() won't expand tuples)
657
+ if itemFilter:
658
+ iid_placeholders = []
659
+ for i, iid in enumerate(itemFilter):
660
+ key = f"iid{i}"
661
+ params[key] = int(iid)
662
+ iid_placeholders.append(":" + key)
663
+ where_clauses.append(f"item_id IN ({', '.join(iid_placeholders)})")
664
+
665
+ # Optional station restriction for ultra-light preload
666
+ if self._restrict_station_ids:
667
+ sid_placeholders = []
668
+ for i, sid in enumerate(self._restrict_station_ids):
669
+ key = f"sid{i}"
670
+ params[key] = int(sid)
671
+ sid_placeholders.append(":" + key)
672
+ where_clauses.append(f"station_id IN ({', '.join(sid_placeholders)})")
673
+
674
+ sql = f"SELECT {columns} FROM StationItem"
675
+ if where_clauses:
676
+ sql += " WHERE " + " AND ".join(where_clauses)
677
+
678
+ tdenv.DEBUG1("query: {}", sql)
679
+ tdenv.DEBUG1("params: {}", params)
680
+ with tdb.engine.connect() as conn:
681
+ result = conn.execute(_sa_text(sql), params)
682
+
683
+ for (
684
+ stnID,
685
+ itmID,
686
+ d_price, d_units, d_level,
687
+ s_price, s_units, s_level,
688
+ modified,
689
+ ) in result:
690
+ rows_seen += 1
691
+ # Compute legacy ageS from modified using parse_ts(.)
692
+ mod_dt = parse_ts(modified)
693
+ if not mod_dt:
694
+ if showProgress:
695
+ sys.stdout.write("\n")
696
+ sys.stdout.flush()
697
+ raise BadTimestampError(tdb, stnID, itmID, modified)
698
+ ageS = nowS - int(mod_dt.timestamp())
699
+
700
+ # Buying map (demand side)
701
+ if d_price and d_price > 0:
702
+ demand[stnID].append((itmID, d_price, d_units or 0, d_level, ageS))
703
+ dmdCount += 1
704
+
705
+ # Selling map (supply side)
706
+ if s_price and s_price > 0:
707
+ supply[stnID].append((itmID, s_price, s_units, s_level, ageS))
708
+ supCount += 1
709
+
710
+ # Calling 'time.time()' is *very* expensive, so only do it every so many rows
711
+ # but the == 1 means that we'll do it for the very first row too.
712
+ if showProgress and (rows_seen & 15) == 1: # fast modulo 16
713
+ heartbeat()
714
+
715
+ if showProgress:
716
+ sys.stdout.write("\n")
717
+ sys.stdout.flush()
718
+
719
+ self._buying_ids = set(self.stationsBuying.keys())
720
+ self._selling_ids = set(self.stationsSelling.keys())
721
+ self.eligible_station_ids = self._buying_ids & self._selling_ids
722
+
723
+ self._dst_buy_map = {}
724
+
725
+ tdenv.DEBUG1(
726
+ "Preload used Engine/Core (no ORM identity map). Rows kept: buys={}, sells={}",
727
+ dmdCount, supCount,
728
+ )
729
+
730
+
731
+ # ------------------------------------------------------------------
732
+ # Cargo fitting algorithms
733
+ # ------------------------------------------------------------------
734
+
735
+
736
+ def bruteForceFit(self, items, credits, capacity, maxUnits): # pylint: disable=redefined-builtin
737
+ """
738
+ Brute-force generation of all possible combinations of items.
739
+ This is provided to make it easy to validate the results of future
740
+ variants or optimizations of the fit algorithm.
741
+ """
742
+
743
+ def _fitCombos(offset, cr, cap, level=1):
744
+ if cr <= 0 or cap <= 0:
745
+ return emptyLoad
746
+ while True:
747
+ if offset >= len(items):
748
+ return emptyLoad
749
+ item = items[offset]
750
+ offset += 1
751
+
752
+ itemCost = item.costCr
753
+ maxQty = min(maxUnits, cap, cr // itemCost)
754
+
755
+ if item.supply < maxQty and item.supply > 0:
756
+ maxQty = min(maxQty, item.supply)
757
+
758
+ if maxQty > 0:
759
+ break
760
+
761
+ bestLoad = _fitCombos(offset, cr, cap, level + 1)
762
+ itemGain = item.gainCr
763
+
764
+ for qty in range(1, maxQty + 1):
765
+ loadGain, loadCost = itemGain * qty, itemCost * qty
766
+ load = TradeLoad(((item, qty),), loadGain, loadCost, qty)
767
+ subLoad = _fitCombos(offset, cr - loadCost, cap - qty, level + 1)
768
+ combGain = loadGain + subLoad.gainCr
769
+ if combGain < bestLoad.gainCr:
770
+ continue
771
+ combCost = loadCost + subLoad.costCr
772
+ combUnits = qty + subLoad.units
773
+ if combGain == bestLoad.gainCr:
774
+ if combUnits > bestLoad.units:
775
+ continue
776
+ if combUnits == bestLoad.units:
777
+ if combCost >= bestLoad.costCr:
778
+ continue
779
+ bestLoad = TradeLoad(
780
+ load.items + subLoad.items, combGain, combCost, combUnits
781
+ )
782
+
783
+ return bestLoad
784
+
785
+ return _fitCombos(0, credits, capacity)
786
+
787
+ def fastFit(self, items, credits, capacity, maxUnits): # pylint: disable=redefined-builtin
788
+ """
789
+ Best load calculator using a recursive knapsack-like
790
+ algorithm to find multiple loads and return the best.
791
+ [eyeonus] Left in for the masochists, as this becomes
792
+ horribly slow at stations with many items for sale.
793
+ As in iooks-like-the-program-has-frozen slow.
794
+ """
795
+
796
+ def _fitCombos(offset, cr, cap):
797
+ """
798
+ Starting from offset, consider a scenario where we
799
+ would purchase the maximum number of each item
800
+ given the cr+cap limitations. Then, assuming that
801
+ load, solve for the remaining cr+cap from the next
802
+ value of offset.
803
+
804
+ The "best fit" is not always the most profitable,
805
+ so we yield all the results and leave the caller
806
+ to determine which is actually most profitable.
807
+ """
808
+ bestGainCr = -1
809
+ bestItem = None
810
+ bestQty = 0
811
+ bestCostCr = 0
812
+ bestSub = None
813
+
814
+ qtyCeil = min(maxUnits, cap)
815
+
816
+ for iNo in range(offset, len(items)):
817
+ item = items[iNo]
818
+ itemCostCr = item.costCr
819
+ maxQty = min(qtyCeil, cr // itemCostCr)
820
+
821
+ if maxQty <= 0:
822
+ continue
823
+
824
+ supply = item.supply
825
+ if supply <= 0:
826
+ continue
827
+
828
+ maxQty = min(maxQty, supply)
829
+
830
+ itemGainCr = item.gainCr
831
+ if maxQty == cap:
832
+ gain = itemGainCr * maxQty
833
+ if gain > bestGainCr:
834
+ cost = itemCostCr * maxQty
835
+ bestGainCr = gain
836
+ bestItem = item
837
+ bestQty = maxQty
838
+ bestCostCr = cost
839
+ bestSub = None
840
+ break
841
+
842
+ loadCostCr = maxQty * itemCostCr
843
+ loadGainCr = maxQty * itemGainCr
844
+ if loadGainCr > bestGainCr:
845
+ bestGainCr = loadGainCr
846
+ bestCostCr = loadCostCr
847
+ bestItem = item
848
+ bestQty = maxQty
849
+ bestSub = None
850
+
851
+ crLeft, capLeft = cr - loadCostCr, cap - maxQty
852
+ if crLeft > 0 and capLeft > 0:
853
+ subLoad = _fitCombos(iNo + 1, crLeft, capLeft)
854
+ if subLoad is emptyLoad:
855
+ continue
856
+ ttlGain = loadGainCr + subLoad.gainCr
857
+ if ttlGain < bestGainCr:
858
+ continue
859
+ ttlCost = loadCostCr + subLoad.costCr
860
+ if ttlGain == bestGainCr and ttlCost >= bestCostCr:
861
+ continue
862
+ bestGainCr = ttlGain
863
+ bestItem = item
864
+ bestQty = maxQty
865
+ bestCostCr = ttlCost
866
+ bestSub = subLoad
867
+
868
+ if not bestItem:
869
+ return emptyLoad
870
+
871
+ bestLoad = ((bestItem, bestQty),)
872
+ if bestSub:
873
+ bestLoad = bestLoad + bestSub.items
874
+ bestQty += bestSub.units
875
+ return TradeLoad(bestLoad, bestGainCr, bestCostCr, bestQty)
876
+
877
+ return _fitCombos(0, credits, capacity)
878
+
879
+ # Mark's test run, to spare searching back through the forum posts for it.
880
+ # python trade.py run --fr="Orang/Bessel Gateway" --cap=720 --cr=11b --ly=24.73 --empty=37.61 --pad=L --hops=2 --jum=3 --loop --summary -vv --progress
881
+ def simpleFit(self, items, credits, capacity, maxUnits): # pylint: disable=redefined-builtin
882
+ """
883
+ Simplistic load calculator:
884
+ (The item list is sorted with highest profit margin items in front.)
885
+ Step 1: Fill hold with as much of item1 as possible based on the limiting
886
+ factors of hold size, supply amount, and available credits.
887
+
888
+ Step 2: If there is space in the hold and money available, repeat Step 1
889
+ with item2, item3, etc. until either the hold is filled
890
+ or the commander is too poor to buy more.
891
+
892
+ When amount of credits isn't a limiting factor, this should produce
893
+ the most profitable route ~99.7% of the time, and still be very
894
+ close to the most profitable the rest of the time.
895
+ (Very close = not enough less profit that anyone should care,
896
+ especially since this thing doesn't suffer slowdowns like fastFit.)
897
+ """
898
+
899
+ n = 0
900
+ load = ()
901
+ gainCr = 0
902
+ costCr = 0
903
+ qty = 0
904
+ while n < len(items) and credits > 0 and capacity > 0:
905
+ qtyCeil = min(maxUnits, capacity)
906
+
907
+ item = items[n]
908
+ maxQty = min(qtyCeil, credits // item.costCr)
909
+
910
+ if maxQty > 0 and item.supply > 0:
911
+ maxQty = min(maxQty, item.supply)
912
+
913
+ loadCostCr = maxQty * item.costCr
914
+ loadGainCr = maxQty * item.gainCr
915
+
916
+ load = load + ((item, maxQty),)
917
+ qty += maxQty
918
+ capacity -= maxQty
919
+
920
+ gainCr += loadGainCr
921
+ costCr += loadCostCr
922
+ credits -= loadCostCr
923
+
924
+ n += 1
925
+
926
+ return TradeLoad(load, gainCr, costCr, qty)
927
+
928
+ # ------------------------------------------------------------------
929
+ # Trading methods
930
+ # ------------------------------------------------------------------
931
+
932
+ def getTrades(self, srcStation, dstStation, srcSelling=None):
933
+ """
934
+ Returns the most profitable trading options from one station to another.
935
+ """
936
+ if not srcSelling:
937
+ srcSelling = self.stationsSelling.get(srcStation.ID, None)
938
+ if not srcSelling:
939
+ self.tdenv.DEBUG2("^- source not selling anything")
940
+ return None
941
+
942
+ dstBuying = self.stationsBuying.get(dstStation.ID, None)
943
+ if not dstBuying:
944
+ self.tdenv.DEBUG2("^- dest not buying anything")
945
+ return None
946
+
947
+ minGainCr = max(1, self.tdenv.minGainPerTon or 1)
948
+ maxGainCr = max(minGainCr, self.tdenv.maxGainPerTon or sys.maxsize)
949
+
950
+ # ---- per-destination buy map cache (item_id -> buy tuple) ----
951
+ buy_map = self._dst_buy_map.get(dstStation.ID)
952
+ if buy_map is None:
953
+ # list -> dict once, re-used across many src comparisons
954
+ buy_map = {buy[0]: buy for buy in dstBuying}
955
+ self._dst_buy_map[dstStation.ID] = buy_map
956
+ getBuy = buy_map.get
957
+
958
+ itemIdx = self.tdb.itemByID
959
+ trading = []
960
+ append_trade = trading.append
961
+
962
+ for sell in srcSelling:
963
+ buy = getBuy(sell[0])
964
+ if not buy:
965
+ continue
966
+ gainCr = buy[1] - sell[1]
967
+ if minGainCr <= gainCr <= maxGainCr:
968
+ append_trade(
969
+ Trade(
970
+ itemIdx[sell[0]],
971
+ sell[1],
972
+ gainCr,
973
+ sell[2],
974
+ sell[3],
975
+ buy[2],
976
+ buy[3],
977
+ sell[4],
978
+ buy[4],
979
+ )
980
+ )
981
+
982
+ # Same final ordering as two successive sorts:
983
+ # primary: gainCr desc, tiebreak: costCr asc
984
+ trading.sort(key=lambda t: (-t.gainCr, t.costCr))
985
+
986
+ return trading
987
+
988
+ def getBestHops(self, routes, restrictTo=None):
989
+ """
990
+ Given a list of routes, try all available next hops from each route.
991
+ Keeps only the best candidate per destination station for this hop.
992
+ """
993
+
994
+ self.aborted = False
995
+ tdb = self.tdb
996
+ tdenv = self.tdenv
997
+ avoidPlaces = getattr(tdenv, "avoidPlaces", None) or ()
998
+ assert not restrictTo or isinstance(restrictTo, set)
999
+ maxJumpsPer = tdenv.maxJumpsPer
1000
+ maxLyPer = tdenv.maxLyPer
1001
+ maxPadSize = tdenv.padSize
1002
+ planetary = tdenv.planetary
1003
+ fleet = tdenv.fleet
1004
+ odyssey = tdenv.odyssey
1005
+ noPlanet = tdenv.noPlanet
1006
+ maxLsFromStar = tdenv.maxLs or float("inf")
1007
+ reqBlackMarket = getattr(tdenv, "blackMarket", False) or False
1008
+ maxAge = getattr(tdenv, "maxAge") or 0
1009
+ credits = tdenv.credits - (getattr(tdenv, "insurance", 0) or 0)
1010
+ fitFunction = self.defaultFit
1011
+ capacity = tdenv.capacity
1012
+ maxUnits = getattr(tdenv, "limit") or capacity
1013
+
1014
+ buying_ids = self._buying_ids
1015
+
1016
+ bestToDest = {}
1017
+ safetyMargin = 1.0 - tdenv.margin
1018
+ unique = tdenv.unique
1019
+ loopInt = getattr(tdenv, "loopInt", 0) or None
1020
+
1021
+ if tdenv.lsPenalty:
1022
+ lsPenalty = max(min(tdenv.lsPenalty / 100, 1), 0)
1023
+ else:
1024
+ lsPenalty = 0
1025
+
1026
+ goalSystem = tdenv.goalSystem
1027
+ uniquePath = None
1028
+
1029
+ # Build restriction set using duck typing to avoid class mismatches
1030
+ restrictStations = set()
1031
+ if restrictTo:
1032
+ for place in restrictTo:
1033
+ # Treat as a station if it has a .system but no .stations
1034
+ if hasattr(place, "system") and not hasattr(place, "stations"):
1035
+ restrictStations.add(place)
1036
+ # Treat as a system if it has .stations
1037
+ elif hasattr(place, "stations") and place.stations:
1038
+ restrictStations.update(place.stations)
1039
+
1040
+ # -----------------------
1041
+ # Spinner (stderr; only with --progress)
1042
+ # -----------------------
1043
+ heartbeat_enabled = bool(getattr(tdenv, "progress", False))
1044
+ hb_interval = 0.5
1045
+ last_hb = 0.0
1046
+ spinner = ("|", "/", "-", "\\")
1047
+ spin_i = 0
1048
+ total_origins = len(routes)
1049
+ best_seen_score = -1 # hop-global best hop score, nearest int
1050
+
1051
+ def heartbeat(origin_idx, dests_checked):
1052
+ nonlocal last_hb, spin_i
1053
+ now = time.time()
1054
+ if now - last_hb < hb_interval:
1055
+ return
1056
+ last_hb = now
1057
+ s = spinner[spin_i]
1058
+ spin_i = (spin_i + 1) % len(spinner)
1059
+ sys.stderr.write(
1060
+ f"\r\x1b[2K{s} origin {origin_idx}/{total_origins} destinations checked: {dests_checked:n} best score: {max(0, best_seen_score):n}"
1061
+ )
1062
+ sys.stderr.flush()
1063
+
1064
+ if tdenv.direct:
1065
+ if goalSystem and not restrictTo:
1066
+ restrictTo = (goalSystem,)
1067
+ # goalSystem is a system; take its stations
1068
+ restrictStations = set(goalSystem.stations or ())
1069
+
1070
+ # Respect --avoid on any prebuilt restriction set
1071
+ if avoidPlaces and restrictStations:
1072
+ restrictStations = {
1073
+ stn for stn in restrictStations
1074
+ if stn not in avoidPlaces and stn.system not in avoidPlaces
1075
+ }
1076
+
1077
+ # **Fix:** when there is no explicit restriction (no --to / --towards),
1078
+ # iterate all eligible destinations rather than an empty set.
1079
+ if not restrictStations:
1080
+ restrictStations = {
1081
+ stn for stn in tdb.stationByID.values()
1082
+ if (stn not in avoidPlaces and stn.system not in avoidPlaces)
1083
+ }
1084
+
1085
+ def station_iterator(srcStation, origin_idx):
1086
+ srcSys = srcStation.system
1087
+ srcDist = srcSys.distanceTo
1088
+ dests_seen = 0
1089
+ for stn in restrictStations:
1090
+ stnSys = stn.system
1091
+ if stn.ID not in buying_ids:
1092
+ continue
1093
+ dests_seen += 1
1094
+ if heartbeat_enabled and (dests_seen & 15) == 1: # fast modulo 16
1095
+ heartbeat(origin_idx, dests_seen)
1096
+ yield Destination(stnSys, stn, (srcSys, stnSys), srcDist(stnSys))
1097
+ if heartbeat_enabled:
1098
+ heartbeat(origin_idx, dests_seen)
1099
+
1100
+ else:
1101
+ getDestinations = tdb.getDestinations
1102
+
1103
+ def station_iterator(srcStation, origin_idx):
1104
+ dests_seen = 0
1105
+ for d in getDestinations(
1106
+ srcStation,
1107
+ maxJumps=maxJumpsPer,
1108
+ maxLyPer=maxLyPer,
1109
+ avoidPlaces=avoidPlaces,
1110
+ maxPadSize=maxPadSize,
1111
+ maxLsFromStar=maxLsFromStar,
1112
+ noPlanet=noPlanet,
1113
+ planetary=planetary,
1114
+ fleet=fleet,
1115
+ odyssey=odyssey,
1116
+ ):
1117
+ if d.station.ID not in buying_ids:
1118
+ continue
1119
+ dests_seen += 1
1120
+ if heartbeat_enabled and (dests_seen & 15) == 1: # fast modulo 16
1121
+ heartbeat(origin_idx, dests_seen)
1122
+ yield d
1123
+ if heartbeat_enabled:
1124
+ heartbeat(origin_idx, dests_seen)
1125
+
1126
+ connections = 0
1127
+ getSelling = self.stationsSelling.get
1128
+
1129
+ for route_no, route in enumerate(routes):
1130
+ try:
1131
+ if tdenv.debug > 1: # route.debug_text can be expensive, so avoid evaluating it
1132
+ tdenv.DEBUG1("Route = {}", route.debug_text(lambda x, y: y))
1133
+
1134
+ srcStation = route.lastStation
1135
+ startCr = credits + int(route.gainCr * safetyMargin)
1136
+
1137
+ srcSelling = getSelling(srcStation.ID, None)
1138
+ if not srcSelling:
1139
+ tdenv.DEBUG1("Nothing sold at source - next.")
1140
+ if heartbeat_enabled:
1141
+ heartbeat(route_no + 1, 0)
1142
+ continue
1143
+
1144
+ srcSelling = tuple(values for values in srcSelling if values[1] <= startCr)
1145
+ if not srcSelling:
1146
+ tdenv.DEBUG1("Nothing affordable - next.")
1147
+ if heartbeat_enabled:
1148
+ heartbeat(route_no + 1, 0)
1149
+ continue
1150
+
1151
+ if goalSystem:
1152
+ origSystem = route.firstSystem
1153
+ srcSystem = srcStation.system
1154
+ srcDistTo = srcSystem.distanceTo
1155
+ goalDistTo = goalSystem.distanceTo
1156
+ origDistTo = origSystem.distanceTo
1157
+ srcGoalDist = srcDistTo(goalSystem)
1158
+ srcOrigDist = srcDistTo(origSystem)
1159
+ origGoalDist = origDistTo(goalSystem)
1160
+
1161
+ if unique:
1162
+ uniquePath = route.route
1163
+ elif loopInt:
1164
+ pos_from_end = 0 - loopInt
1165
+ uniquePath = route.route[pos_from_end:-1]
1166
+
1167
+ stations = (
1168
+ d
1169
+ for d in station_iterator(srcStation, route_no + 1)
1170
+ if (d.station != srcStation)
1171
+ and (d.station.blackMarket == "Y" if reqBlackMarket else True)
1172
+ and (d.station not in uniquePath if uniquePath else True)
1173
+ and (d.station in restrictStations if restrictStations else True)
1174
+ and (d.station.dataAge and d.station.dataAge <= maxAge if maxAge else True)
1175
+ and (
1176
+ (
1177
+ (d.system is not srcSystem)
1178
+ if bool(tdenv.unique)
1179
+ else (d.system is goalSystem or d.distLy < srcGoalDist)
1180
+ )
1181
+ if goalSystem
1182
+ else True
1183
+ )
1184
+ )
1185
+
1186
+ # Even when we don't log the line, we still have to produce the
1187
+ # parameters, and building the route list could be expensive,
1188
+ # so only pay the cost when we're actually logging.
1189
+ if tdenv.debug > 1:
1190
+ def annotate(dest):
1191
+ tdenv.DEBUG1(
1192
+ "destSys {}, destStn {}, jumps {}, distLy {}",
1193
+ dest.system.dbname,
1194
+ dest.station.dbname,
1195
+ "->".join(jump.text() for jump in dest.via),
1196
+ dest.distLy,
1197
+ )
1198
+ return dest
1199
+
1200
+ stations = (annotate(d) for d in stations)
1201
+
1202
+ for dest in stations:
1203
+ dstStation = dest.station
1204
+ connections += 1
1205
+
1206
+ items = self.getTrades(srcStation, dstStation, srcSelling)
1207
+ if not items:
1208
+ continue
1209
+ trade = fitFunction(items, startCr, capacity, maxUnits)
1210
+
1211
+ multiplier = 1.0
1212
+ # Calculate total K-lightseconds supercruise time.
1213
+ # This will amortize for the start/end stations
1214
+ dstSys = dest.system
1215
+ if goalSystem and dstSys is not goalSystem:
1216
+ # Biggest reward for shortening distance to goal
1217
+ dstGoalDist = goalDistTo(dstSys)
1218
+ # bias towards bigger reductions
1219
+ score = 5000 * origGoalDist / dstGoalDist
1220
+ # discourage moving back towards origin
1221
+ score += 50 * srcGoalDist / dstGoalDist
1222
+ # Gain per unit pays a small part
1223
+ if dstSys is not origSystem:
1224
+ score += 10 * (origDistTo(dstSys) - srcOrigDist)
1225
+ score += (trade.gainCr / trade.units) / 25
1226
+ else:
1227
+ score = trade.gainCr
1228
+
1229
+ if lsPenalty:
1230
+ # [kfsone] Only want 1dp
1231
+ # Produce a curve that favors distances under 1kls
1232
+ # positively, starts to penalize distances over 1k,
1233
+ # and after 4kls starts to penalize aggressively
1234
+ # http://goo.gl/Otj2XP
1235
+
1236
+ # [eyeonus] As aadler pointed out, this goes into negative
1237
+ # numbers, which causes problems.
1238
+ # penalty = ((cruiseKls ** 2) - cruiseKls) / 3
1239
+ # penalty *= lsPenalty
1240
+ # multiplier *= (1 - penalty)
1241
+ cruiseKls = int(dstStation.lsFromStar / 100) / 10
1242
+ boost = (1 - sigmoid(25 * (cruiseKls - 1))) / 4
1243
+ drop = (-1 - sigmoid(50 * (cruiseKls - 4))) / 4
1244
+ try:
1245
+ penalty = (-1 + 1 / (cruiseKls + 1) ** ((cruiseKls + 1) / 4)) / 2
1246
+ except OverflowError:
1247
+ penalty = -0.5
1248
+ multiplier += (penalty + boost + drop) * lsPenalty
1249
+
1250
+ score *= multiplier
1251
+
1252
+ # update hop-global best score (nearest int)
1253
+ try:
1254
+ si = int(round(score))
1255
+ except TypeError:
1256
+ si = int(score)
1257
+ if si > best_seen_score:
1258
+ best_seen_score = si
1259
+
1260
+ dstID = dstStation.ID
1261
+ try:
1262
+ btd = bestToDest[dstID]
1263
+ except KeyError:
1264
+ pass
1265
+ else:
1266
+ bestRoute = btd[1]
1267
+ bestScore = btd[5]
1268
+ bestTradeScore = bestRoute.score + bestScore
1269
+ newTradeScore = route.score + score
1270
+ if bestTradeScore > newTradeScore:
1271
+ continue
1272
+ if bestTradeScore == newTradeScore:
1273
+ bestLy = btd[4]
1274
+ if bestLy <= dest.distLy:
1275
+ continue
1276
+
1277
+ bestToDest[dstID] = (
1278
+ dstStation,
1279
+ route,
1280
+ trade,
1281
+ dest.via,
1282
+ dest.distLy,
1283
+ score,
1284
+ )
1285
+ except KeyboardInterrupt:
1286
+ self.aborted = True
1287
+ if not bestToDest:
1288
+ # Let the caller decide how to handle, explicitly
1289
+ raise
1290
+ break
1291
+
1292
+ if heartbeat_enabled:
1293
+ sys.stderr.write("\n")
1294
+ sys.stderr.flush()
1295
+
1296
+ if connections == 0:
1297
+ raise NoHopsError("No destinations could be reached within the constraints.")
1298
+
1299
+ return [
1300
+ route.plus(dst, trade, jumps, score)
1301
+ for (dst, route, trade, jumps, _, score) in bestToDest.values()
1302
+ ]