Jelajahi Sumber

Scale_Metrics_To_Planetary_Base

Updated ROI calculations and UI to display metrics scaled to a full 500-area planetary base rather than a single area.

        PURPOSE:
        To better align the dashboard with actual gameplay mechanics. Players plan production across full bases (500 area limit) rather than individual 1-area plots. Displaying 'Profit per Base', 'Logistics per Base', and 'Market Capacity (Bases)' gives users immediately actionable data at the strategic level.

        IMPLEMENTATION DETAILS:
        - `roi.py`: Modified `market_capacity_base` and `logistics_per_base` calculations to divide by `(area / 500)` rather than just `area`. Updated the `Profit` dataclass keys.
        - `ts/roi.ts`: Updated the dynamic mapping calculation for `profit_per_base` using the `(area / 500)` divisor.
        - `ts/roi.ts`: Adjusted the UI color mapping thresholds proportionally (500x). For example, the profit color scale was increased from `[0, 300]` to `[0, 150000]`.
        - `ts/roi.ts`: Renamed table headers to "Profit/Base", "Logistics/Base", and "Market Cap (Bases)" and rewrote all associated hover tooltips to explicitly explain the 500-area scaling math.
Thomas Knott 2 minggu lalu
induk
melakukan
d6f978f4ae
2 mengubah file dengan 45 tambahan dan 40 penghapusan
  1. 16 21
      roi.py
  2. 29 19
      ts/roi.ts

+ 16 - 21
roi.py

@@ -18,7 +18,6 @@ def main() -> None:
 
 def calc_for_cx(cx: str, recipes: typing.Collection[Recipe], buildings: typing.Mapping[str, Building],
 		materials: typing.Mapping[str, Material], raw_prices: typing.Collection[RawPrice]) -> typing.Sequence[Profit]:
-	# EXTREME DETAIL: We extract 'Bid' and 'Ask' directly from the raw_prices API payload.
 	prices: dict[str, Price] = {
 		p['MaterialTicker']: Price(p['VWAP7D'], p['AverageTraded7D'], p['VWAP30D'], p['Bid'], p['Ask']) for p in raw_prices # pyright: ignore[reportArgumentType]
 		if p['ExchangeCode'] == cx
@@ -45,8 +44,6 @@ def calc_for_cx(cx: str, recipes: typing.Collection[Recipe], buildings: typing.M
 	return profits
 
 def get_metrics(amount: float, price: Price) -> dict[str, float]:
-	# EXTREME DETAIL: Helper function to generate a 3-part dictionary (VWAP, Bid, Ask) for any item quantity.
-	# If Bid or Ask data is absent from the exchange, we gracefully fall back to the VWAP to prevent math crashes.
 	v = price.vwap_7d or price.vwap_30d or 0.0
 	b = price.bid if price.bid is not None else v
 	a = price.ask if price.ask is not None else v
@@ -73,7 +70,6 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 			return # skip recipes with thinly traded outputs
 		output_prices[output['material_ticker']] = typing.cast(PriceNonNull, price)
 		
-		# EXTREME DETAIL: Calculate total daily revenue outputs per metric type.
 		m = get_metrics(output['material_amount'] * runs_per_day, price)
 		for k in revenue: revenue[k] += m[k]
 		outputs.append(MatPrice(output['material_ticker'], output['material_amount'], price.vwap_7d, price.bid, price.ask))
@@ -103,15 +99,18 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 	output_per_day = lowest_liquidity['material_amount'] * runs_per_day
 	
 	average_traded_7d = output_prices[lowest_liquidity['material_ticker']].average_traded_7d
-	output_per_area = output_per_day / area
-	market_capacity_area = average_traded_7d / output_per_area
+	
+	# EXTREME DETAIL: We convert our metrics from 'per area' to 'per base' (1 base = 500 area).
+	# We divide the building's area footprint by 500 to determine what fraction of a base it consumes.
+	output_per_base = output_per_day / (area / 500)
+	market_capacity_base = average_traded_7d / output_per_base
 
-	logistics_per_area = max(
+	logistics_per_base = max(
 		sum(materials[input['material_ticker']]['weight'] * input['material_amount'] for input in recipe['inputs']),
 		sum(materials[input['material_ticker']]['volume'] * input['material_amount'] for input in recipe['inputs']),
 		sum(materials[output['material_ticker']]['weight'] * output['material_amount'] for output in recipe['outputs']),
 		sum(materials[output['material_ticker']]['volume'] * output['material_amount'] for output in recipe['outputs']),
-	) * runs_per_day / area
+	) * runs_per_day / (area / 500)
 	
 	return Profit(outputs, recipe['recipe_name'],
 			expertise=building['expertise'],
@@ -122,10 +121,10 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 			revenue=revenue,
 			input_costs=input_costs,
 			runs_per_day=runs_per_day,
-			logistics_per_area=logistics_per_area,
+			logistics_per_base=logistics_per_base,
 			output_per_day=output_per_day,
 			average_traded_7d=average_traded_7d,
-			market_capacity_area=market_capacity_area)
+			market_capacity_base=market_capacity_base)
 
 def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> dict[str, float]:
 	cost = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
@@ -192,8 +191,8 @@ class RawPrice(typing.TypedDict):
 	VWAP7D: float | None
 	AverageTraded7D: float | None
 	VWAP30D: float | None
-	Bid: float | None  # Added Bid extraction
-	Ask: float | None  # Added Ask extraction
+	Bid: float | None
+	Ask: float | None
 
 @dataclasses.dataclass(eq=False, frozen=True, slots=True)
 class Price:
@@ -215,21 +214,17 @@ class Profit:
 	expertise: str
 	building: str
 	area: float
-	capex: dict[str, float]      # Transformed from float to Dict
-	opex: dict[str, float]       # Transformed from float to Dict
-	revenue: dict[str, float]    # Transformed from float to Dict
+	capex: dict[str, float]
+	opex: dict[str, float]
+	revenue: dict[str, float]
 	input_costs: typing.Collection[MatPrice]
 	runs_per_day: float
-	logistics_per_area: float
+	logistics_per_base: float    # Renamed from area to base
 	output_per_day: float
 	average_traded_7d: float
-	market_capacity_area: float 
+	market_capacity_base: float  # Renamed from area to base
 
 	def __lt__(self, other: Profit) -> bool:
-		# EXTREME DETAIL: We establish a baseline VWAP sort for the raw JSON payload.
-		# Even though the frontend now dynamically resorts based on UI dropdown permutations,
-		# sorting the initial JSON correctly saves the client from experiencing a 'pop-in' rearrangement
-		# on their very first page load.
 		p_a = self.revenue['vwap'] - self.opex['vwap']
 		be_a = (self.capex['vwap'] + 3 * self.opex['vwap']) / p_a if p_a > 0 else 10000 - p_a
 		

+ 29 - 19
ts/roi.ts

@@ -99,22 +99,26 @@ async function render() {
 
 	if (!headersInitialized) {
 		const ths = document.querySelectorAll('th');
+		// Map our new "_base" keys to the DOM headers
 		const keys: (keyof ProfitWithMetrics | 'outputs')[] = [
-			'outputs', 'expertise', 'profit_per_area', 'break_even', 
-			'capex_val', 'opex_val', 'logistics_per_area', 'market_capacity_area'
+			'outputs', 'expertise', 'profit_per_base', 'break_even', 
+			'capex_val', 'opex_val', 'logistics_per_base', 'market_capacity_base'
 		];
 		
 		ths.forEach((th, i) => {
 			if (keys[i]) {
 				th.style.cursor = 'pointer';
-				th.title = ''; // Remove native title to prevent double-tooltips
+				th.title = ''; 
 				
-				// EXTREME DETAIL: We inject dynamic string content and custom tooltips directly into the DOM headers.
-				// By routing these explanations into `dataset.tooltip`, the existing popover engine picks them up
-				// automatically, rendering a clean, stylized popup on hover.
-				if (keys[i] === 'market_capacity_area') {
-					th.textContent = 'Market Cap (Areas)';
-					th.dataset.tooltip = 'Click to sort.\n\nMarket Capacity: 7-day average traded volume ÷ daily output per area. Indicates how many areas you can build before saturating the market.';
+				if (keys[i] === 'profit_per_base') {
+					th.textContent = 'Profit/Base';
+					th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.';
+				} else if (keys[i] === 'logistics_per_base') {
+					th.textContent = 'Logistics/Base';
+					th.dataset.tooltip = 'Click to sort.\n\nDaily logistics volume/weight scaled to a full 500-area planetary base.';
+				} else if (keys[i] === 'market_capacity_base') {
+					th.textContent = 'Market Cap (Bases)';
+					th.dataset.tooltip = 'Click to sort.\n\nMarket Capacity: 7-day average traded volume ÷ daily output per base. Indicates how many full 500-area bases you can build before saturating the market.';
 				} else if (keys[i] === 'break_even') {
 					th.dataset.tooltip = 'Click to sort.\n\nBreak Even: (CapEx + 3 days of OpEx) ÷ daily profit. Includes 3 days of operating costs as working capital.';
 				} else {
@@ -173,10 +177,11 @@ async function render() {
 		const revenue_val = p.revenue[revenueMetric];
 		
 		const profit_per_day = revenue_val - opex_val;
-		const profit_per_area = profit_per_day / p.area;
+		// EXTREME DETAIL: Divide by the fraction of a base the building consumes.
+		const profit_per_base = profit_per_day / (p.area / 500);
 		const break_even = profit_per_day > 0 ? (capex_val + 3 * opex_val) / profit_per_day : Infinity;
 		
-		return { ...p, capex_val, opex_val, revenue_val, profit_per_day, profit_per_area, break_even };
+		return { ...p, capex_val, opex_val, revenue_val, profit_per_day, profit_per_base, break_even };
 	});
 
 	profitsWithMetrics.sort((a, b) => {
@@ -194,17 +199,22 @@ async function render() {
 	});
 
 	for (const p of profitsWithMetrics) {
+		const volumeRatio = p.output_per_day / p.average_traded_7d;
 		const tr = document.createElement('tr');
 		
+		// EXTREME DETAIL: Because 1 base = 500 area, the color scales must be adjusted 
+		// proportionately to ensure the visual gradients still render accurately.
+		// e.g. Profit bounds changed from 300 to 150,000. 
+		// Market capacity bounds changed from [20, 500] to [0.04, 1].
 		tr.innerHTML = `
 			<td>${p.outputs.map(o => o.ticker).join(', ')}</td>
 			<td>${expertise[p.expertise]}</td>
-			<td style="color: ${color(p.profit_per_area, 0, 300)}">${formatDecimal(p.profit_per_area)}</td>
+			<td style="color: ${color(p.profit_per_base, 0, 150000)}">${formatDecimal(p.profit_per_base)}</td>
 			<td><span style="color: ${color(p.break_even, 30, 3)}">${formatDecimal(p.break_even)}</span>d</td>
 			<td style="color: ${color(p.capex_val, 300_000, 40_000)}">${formatWhole(p.capex_val)}</td>
 			<td style="color: ${color(p.opex_val, 40_000, 1_000)}">${formatWhole(p.opex_val)}</td>
-			<td style="color: ${color(p.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
-			<td style="color: ${color(p.market_capacity_area, 20, 500)}">${formatWhole(p.market_capacity_area)}</td>
+			<td style="color: ${color(p.logistics_per_base, 1000, 100)}">${formatDecimal(p.logistics_per_base)}</td>
+			<td style="color: ${color(p.market_capacity_base, 0.04, 1)}">${formatDecimal(p.market_capacity_base)}</td>
 		`;
 
 		const output = tr.querySelector('td')!;
@@ -214,10 +224,10 @@ async function render() {
 		profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, p.runs_per_day) + '\n\n' +
 			formatMatPrices(p.input_costs, opexMetric, p.runs_per_day) + '\n' +
 			`(${formatWhole(p.revenue_val)} - ${formatWhole(p.opex_val)}) = ${formatDecimal(p.profit_per_day)}\n` +
-			`${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(p.profit_per_area)}`;
+			`${formatDecimal(p.profit_per_day)} / ${formatDecimal(p.area / 500)} bases = ${formatDecimal(p.profit_per_base)}`;
 
 		const marketCell = tr.querySelectorAll('td')[7];
-		marketCell.dataset.tooltip = `Market Capacity: ${formatWhole(p.average_traded_7d)} traded/day ÷ ${formatDecimal(p.output_per_day / p.area)} produced/day/area = ${formatWhole(p.market_capacity_area)} equivalent areas`;
+		marketCell.dataset.tooltip = `Market Capacity: ${formatWhole(p.average_traded_7d)} traded/day ÷ ${formatDecimal(p.output_per_day / (p.area / 500))} produced/day/base = ${formatDecimal(p.market_capacity_base)} equivalent bases`;
 
 		tbody.appendChild(tr);
 	}
@@ -276,10 +286,10 @@ interface Profit {
 	revenue: Metrics
 	input_costs: MatPrice[]
 	runs_per_day: number
-	logistics_per_area: number
+	logistics_per_base: number
 	output_per_day: number
 	average_traded_7d: number
-	market_capacity_area: number
+	market_capacity_base: number
 }
 
 interface ProfitWithMetrics extends Profit {
@@ -287,7 +297,7 @@ interface ProfitWithMetrics extends Profit {
 	opex_val: number;
 	revenue_val: number;
 	profit_per_day: number;
-	profit_per_area: number;
+	profit_per_base: number;
 	break_even: number;
 }