supply.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. from __future__ import annotations
  2. import collections
  3. import dataclasses
  4. import json
  5. import math
  6. import typing
  7. import tap
  8. import cache
  9. from config import config
  10. class Args(tap.Tap):
  11. planets: tuple[str, ...]
  12. weight: float
  13. volume: float
  14. def configure(self) -> None:
  15. self.add_argument('planets', nargs='+', metavar='planet') # take planets as positional args instead of flag
  16. def main() -> None:
  17. args = Args().parse_args()
  18. planets = [Planet(fio_burn) for fio_burn in get_fio_burn(args.planets)]
  19. raw_materials: typing.Sequence[Material] = cache.get('https://rest.fnar.net/material/allmaterials')
  20. materials = {mat['Ticker']: mat for mat in raw_materials}
  21. target_days = float('inf')
  22. for planet in planets:
  23. vol_per_day = weight_per_day = 0
  24. for consumption in planet.net_consumption:
  25. ticker = consumption['MaterialTicker']
  26. vol_per_day += materials[ticker]['Volume'] * consumption['net_consumption']
  27. weight_per_day += materials[ticker]['Weight'] * consumption['net_consumption']
  28. days = planet.inventory.get(ticker, 0) / consumption['net_consumption']
  29. if days < target_days:
  30. target_days = days
  31. print(planet.name, f'consumes {vol_per_day:.1f}㎥, {weight_per_day:.1f}t per day')
  32. optimal: dict[str, dict[str, int]] = None # pyright: ignore[reportAssignmentType]
  33. total_weight_used: float = None # pyright: ignore[reportAssignmentType]
  34. total_volume_used: float = None # pyright: ignore[reportAssignmentType]
  35. target_days = round(target_days + 0.05, 1)
  36. load_more = True
  37. while load_more:
  38. buys: dict[str, dict[str, int]] = {}
  39. iteration_weight = iteration_volume = 0
  40. for planet in planets:
  41. buy, weight_used, volume_used = planet.buy_for_target(materials, target_days)
  42. iteration_weight += weight_used
  43. iteration_volume += volume_used
  44. if iteration_weight > args.weight or iteration_volume > args.volume:
  45. load_more = False
  46. break
  47. buys[planet.name] = buy
  48. if load_more:
  49. optimal = buys
  50. total_weight_used = iteration_weight
  51. total_volume_used = iteration_volume
  52. target_days += 0.1
  53. print('supply for', round(target_days, 1), 'days,', end=' ')
  54. print(f'consuming {round(total_weight_used, 1)}t and {round(total_volume_used, 1)}㎥') # pyright: ignore[reportPossiblyUnboundVariable]
  55. for planet in planets:
  56. print('\n' + cyan(planet.name))
  57. for consumption in planet.net_consumption:
  58. ticker = consumption['MaterialTicker']
  59. avail = planet.inventory.get(ticker, 0)
  60. daily_consumption = consumption['net_consumption']
  61. days = avail / daily_consumption
  62. print(f'{ticker:>3}: {avail:5d} ({daily_consumption:8.2f}/d) {days:4.1f} d', end='')
  63. if need := optimal[planet.name].get(ticker): # pyright: ignore[reportOptionalMemberAccess]
  64. print(f' | {need:8.1f}')
  65. else:
  66. print()
  67. combined_buy: dict[str, int] = collections.defaultdict(int)
  68. for buy in optimal.values():
  69. for ticker, amount in buy.items():
  70. combined_buy[ticker] += amount
  71. print(cyan('\nbuy:\n') + json.dumps({
  72. 'actions': [
  73. {'name': 'BuyItems', 'type': 'CX Buy', 'group': 'A1', 'exchange': 'IC1',
  74. 'priceLimits': {}, 'buyPartial': False, 'useCXInv': True},
  75. {'type': 'MTRA', 'name': 'TransferAction', 'group': 'A1',
  76. 'origin': 'Hortus Station Warehouse', 'dest': 'Configure on Execution'},
  77. ],
  78. 'global': {'name': 'supply ' + ' '.join(args.planets)},
  79. 'groups': [{
  80. 'type': 'Manual', 'name': 'A1', 'materials': {mat: amount for mat, amount in combined_buy.items()}
  81. }],
  82. }))
  83. for planet in planets:
  84. buy = optimal[planet.name]
  85. print(cyan(f'unload {planet.name}:\n') + json.dumps({
  86. 'actions': [
  87. {'type': 'MTRA', 'name': 'TransferAction', 'group': 'A1',
  88. 'origin': 'Configure on Execution', 'dest': planet.name + ' Base'},
  89. ],
  90. 'global': {'name': 'unload ' + planet.name},
  91. 'groups': [{
  92. 'type': 'Manual', 'name': 'A1', 'materials': {mat: amount for mat, amount in buy.items()}
  93. }],
  94. }))
  95. def get_fio_burn(planet_names: typing.Sequence[str]) -> typing.Iterator[FIOBurn]:
  96. planets: list[FIOBurn] = cache.get('https://rest.fnar.net/fioweb/burn/user/' + config.username,
  97. headers={'Authorization': config.fio_api_key})
  98. for name in planet_names:
  99. name = name.casefold()
  100. for planet_data in planets:
  101. if name in (planet_data['PlanetName'].casefold(), planet_data['PlanetNaturalId'].casefold()):
  102. assert planet_data['Error'] is None
  103. yield planet_data
  104. break
  105. else:
  106. raise ValueError(name + ' not found')
  107. def cyan(text: str) -> str:
  108. return '\033[36m' + text + '\033[0m'
  109. class FIOBurn(typing.TypedDict):
  110. PlanetName: str
  111. PlanetNaturalId: str
  112. Error: typing.Any
  113. OrderConsumption: list[Amount]
  114. WorkforceConsumption: list[Amount]
  115. Inventory: list[Inventory]
  116. OrderProduction: list[Amount]
  117. @dataclasses.dataclass(init=False, eq=False, slots=True)
  118. class Planet:
  119. name: str
  120. inventory: dict[str, int]
  121. net_consumption: typing.Sequence[Amount]
  122. def __init__(self, fio_burn: FIOBurn) -> None:
  123. self.name = fio_burn['PlanetName'] or fio_burn['PlanetNaturalId']
  124. self.inventory = {item['MaterialTicker']: item['MaterialAmount'] for item in fio_burn['Inventory']}
  125. producing = {item['MaterialTicker']: item for item in fio_burn['OrderProduction']}
  126. self.net_consumption = []
  127. for c in fio_burn['OrderConsumption'] + fio_burn['WorkforceConsumption']:
  128. net = c['DailyAmount']
  129. if production := producing.get(c['MaterialTicker']):
  130. net -= production['DailyAmount']
  131. if net < 0:
  132. continue
  133. c['net_consumption'] = net
  134. self.net_consumption.append(c)
  135. def buy_for_target(self, materials: dict[str, Material], target_days: float) -> tuple[dict[str, int], float, float]:
  136. weight_used = volume_used = 0
  137. buy: dict[str, int] = {}
  138. for consumption in self.net_consumption:
  139. ticker = consumption['MaterialTicker']
  140. avail = self.inventory.get(ticker, 0)
  141. daily_consumption = consumption['net_consumption']
  142. days = avail / daily_consumption
  143. if days < target_days:
  144. buy[ticker] = math.ceil((target_days - days) * daily_consumption)
  145. weight_used += buy[ticker] * materials[ticker]['Weight']
  146. volume_used += buy[ticker] * materials[ticker]['Volume']
  147. return buy, weight_used, volume_used
  148. class Amount(typing.TypedDict):
  149. MaterialTicker: str
  150. DailyAmount: float
  151. net_consumption: float
  152. class Inventory(typing.TypedDict):
  153. MaterialTicker: str
  154. MaterialAmount: int
  155. class Material(typing.TypedDict):
  156. Ticker: str
  157. Weight: float
  158. Volume: float
  159. if __name__ == '__main__':
  160. main()