Эх сурвалжийг харах

Add_Dynamic_OpEx_CapEx_Integration_and_3SigFig_Format

Merged working capital into CapEx conditionally and universally applied 3-significant-figure formatting.

        PURPOSE:
        To increase the accuracy and legibility of the ROI dashboard. Structurally moving working capital (days of OpEx) into the CapEx bucket allows the user to see the true upfront cost required to initiate a production line, while adding a persistent DOM numerical input empowers them to dynamically model aggressive vs. conservative supply chains. Additionally, universally enforcing a 3-significant-figure standard via `Intl.NumberFormat('compact')` drastically reduces visual clutter across the entire table, and explicitly tagging the Logistics column with a bottleneck suffix prevents ambiguity regarding freighter constraints.

        IMPLEMENTATION DETAILS:
        - `roi.py`: Refactored the internal `max()` constraint check for logistics to store and pass forward a `logistics_bottleneck` string (e.g., `t (I)` or `m³ (O)`).
        - `ts/roi.ts`: Defined `formatSigFig` using `Intl.NumberFormat({ notation: 'compact', maximumSignificantDigits: 3 })`. Stripped out all previous formatters (`formatWhole`, `formatDecimal`) and applied `formatSigFig` to all cell rendering and tooltip building loops.
        - `ts/roi.ts`: Added `<input type="number" id="working-capital">` to the DOM header block. Registered event listeners and `localStorage` syncing for dynamic state preservation.
        - `ts/roi.ts`: Abstracted the `break_even` working capital addition logic and moved it directly into the `capex_val` derivation mapping. `break_even` now calculates universally as `capex_val / profit_per_base`.
        - `ts/roi.ts`: Overhauled tooltips for CapEx, Break Even, and Logistics to explicitly break down and clarify the new mathematical inclusions and suffix meanings.
Thomas Knott 2 долоо хоног өмнө
parent
commit
a148c65f5d
6 өөрчлөгдсөн 889 нэмэгдсэн , 498 устгасан
  1. 15 6
      roi.py
  2. 48 27
      ts/roi.ts
  3. 207 116
      www/roi_ai1.json
  4. 207 116
      www/roi_ci1.json
  5. 206 116
      www/roi_ic1.json
  6. 206 117
      www/roi_nc1.json

+ 15 - 6
roi.py

@@ -103,7 +103,6 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 	output_per_base = output_per_day / (area / 500)
 	market_capacity_base = average_traded_7d / output_per_base
 
-	# EXTREME DETAIL: Abstracted the 4 core logistics metrics into explicit variables for readability.
 	in_w = sum(materials[input['material_ticker']]['weight'] * input['material_amount'] for input in recipe['inputs'])
 	in_v = sum(materials[input['material_ticker']]['volume'] * input['material_amount'] for input in recipe['inputs'])
 	out_w = sum(materials[output['material_ticker']]['weight'] * output['material_amount'] for output in recipe['outputs'])
@@ -111,13 +110,20 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 
 	runs_per_base = runs_per_day / (area / 500)
 	
-	# EXTREME DETAIL: We compute the fraction of ship cargo space required per base per day.
-	# We evaluate the maximum bottleneck across the round trip (assuming 3000 weight OR 1000 volume limits).
-	# This ship fraction is then multiplied by 800,000 (the capital cost of a single ship).
 	ships_needed_per_base = max(in_w / 3000, in_v / 1000, out_w / 3000, out_v / 1000) * runs_per_base
 	ship_capex_per_base = ships_needed_per_base * 800_000
 
-	logistics_per_base = max(in_w, in_v, out_w, out_v) * runs_per_base
+	# EXTREME DETAIL: We decouple the max() bottleneck calculation to identify EXACTLY
+	# which metric limits the supply chain. This string ('t (I)', 'm³ (O)', etc.) is 
+	# passed to the frontend to append to the numerical value.
+	bottlenecks = [
+		(in_w, 't (I)'),
+		(in_v, 'm³ (I)'),
+		(out_w, 't (O)'),
+		(out_v, 'm³ (O)')
+	]
+	max_logistics, logistics_bottleneck = max(bottlenecks, key=lambda x: x[0])
+	logistics_per_base = max_logistics * runs_per_base
 	
 	return Profit(outputs, recipe['recipe_name'],
 			expertise=building['expertise'],
@@ -129,6 +135,7 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 			input_costs=input_costs,
 			runs_per_day=runs_per_day,
 			logistics_per_base=logistics_per_base,
+			logistics_bottleneck=logistics_bottleneck,
 			output_per_day=output_per_day,
 			average_traded_7d=average_traded_7d,
 			market_capacity_base=market_capacity_base,
@@ -228,16 +235,18 @@ class Profit:
 	input_costs: typing.Collection[MatPrice]
 	runs_per_day: float
 	logistics_per_base: float
+	logistics_bottleneck: str    # Added bottleneck string indicator
 	output_per_day: float
 	average_traded_7d: float
 	market_capacity_base: float
-	ship_capex_per_base: float # Added pre-calculated property tracking
+	ship_capex_per_base: float
 
 	def __lt__(self, other: Profit) -> bool:
 		bases_a = self.area / 500
 		p_a = (self.revenue['vwap'] - self.opex['vwap']) / bases_a
 		c_a = self.capex['vwap'] / bases_a
 		o_a = self.opex['vwap'] / bases_a
+		# We default to a 3-day baseline here to ensure the backend JSON is sorted logically.
 		be_a = (c_a + 3 * o_a) / p_a if p_a > 0 else 10000 - p_a
 		
 		bases_b = other.area / 500

+ 48 - 27
ts/roi.ts

@@ -33,9 +33,13 @@ for (const key of Object.keys(expertise)) {
 }
 const buildingSelect = document.querySelector('select#building') as HTMLSelectElement;
 
-const formatDecimal = new Intl.NumberFormat(undefined,
-		{maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: 'lessPrecision'}).format;
-const formatWhole = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0}).format;
+// EXTREME DETAIL: We replace the distinct `formatWhole` and `formatDecimal` formatters with a unified 
+// `formatSigFig` formatter. Utilizing the `compact` notation automatically caps the output at 
+// 3 significant figures and organically appends "K", "M", etc. to large numbers.
+const formatSigFig = new Intl.NumberFormat(undefined, {
+	notation: 'compact',
+	maximumSignificantDigits: 3,
+}).format;
 
 if (localStorage.getItem('roi-cx')) cxSelect.value = localStorage.getItem('roi-cx')!;
 if (localStorage.getItem('roi-expertise')) expertiseSelect.value = localStorage.getItem('roi-expertise')!;
@@ -53,6 +57,9 @@ let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricT
 let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
 let includeShips: boolean = localStorage.getItem('roi-include-ships') === 'true';
 
+// EXTREME DETAIL: Track the state of the user's working capital input. Default to 3 days.
+let workingCapitalDays: number = parseInt(localStorage.getItem('roi-working-capital') || '3', 10);
+
 async function render() {
 	const tbody = document.querySelector('tbody')!;
 	tbody.innerHTML = '';
@@ -65,7 +72,8 @@ async function render() {
 	if (!metricControlsInitialized) {
 		const controls = document.createElement('div');
 		controls.style.marginBottom = '15px';
-		// EXTREME DETAIL: Added a new checkbox for 'Include Ship CapEx' directly alongside the dropdowns.
+		// EXTREME DETAIL: Added the numerical input `<input type="number">` allowing users to configure
+		// exactly how many days of OpEx should be buffered into their CapEx.
 		controls.innerHTML = `
 			<label style="margin-right: 15px;">CapEx Price: 
 				<select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
@@ -76,9 +84,12 @@ async function render() {
 			<label style="margin-right: 15px;">Revenue Price: 
 				<select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
 			</label>
-			<label>
+			<label style="margin-right: 15px;">
 				<input type="checkbox" id="include-ships"> Include Ship CapEx
 			</label>
+			<label>
+				<input type="number" id="working-capital" min="0" step="1" style="width: 50px;"> Days OpEx
+			</label>
 		`;
 		const table = document.querySelector('table');
 		if (table) table.parentNode?.insertBefore(controls, table);
@@ -87,6 +98,7 @@ async function render() {
 		(document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
 		(document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
 		(document.getElementById('include-ships') as HTMLInputElement).checked = includeShips;
+		(document.getElementById('working-capital') as HTMLInputElement).value = workingCapitalDays.toString();
 
 		document.getElementById('capex-metric')!.addEventListener('change', (e) => {
 			capexMetric = (e.target as HTMLSelectElement).value as MetricType;
@@ -104,6 +116,10 @@ async function render() {
 			includeShips = (e.target as HTMLInputElement).checked;
 			render();
 		});
+		document.getElementById('working-capital')!.addEventListener('change', (e) => {
+			workingCapitalDays = parseInt((e.target as HTMLInputElement).value, 10);
+			render();
+		});
 		metricControlsInitialized = true;
 	}
 
@@ -124,18 +140,18 @@ async function render() {
 					th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.';
 				} else if (keys[i] === 'capex_val') {
 					th.textContent = 'CapEx/Base';
-					th.dataset.tooltip = 'Click to sort.\n\nTotal capital expenditure (construction + habitation costs) scaled to a full 500-area planetary base.\nIf "Include Ship CapEx" is toggled, adds the cost of ships (800k each) required for daily logistics.';
+					th.dataset.tooltip = 'Click to sort.\n\nTotal capital expenditure scaled to a full 500-area planetary base.\nIncludes base construction, working capital (days of OpEx), and optional Ship CapEx.';
 				} else if (keys[i] === 'opex_val') {
 					th.textContent = 'OpEx/Base';
 					th.dataset.tooltip = 'Click to sort.\n\nDaily operational expenditure (input materials + worker consumables) 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.';
+					th.dataset.tooltip = 'Click to sort.\n\nDaily logistics bottleneck scaled to a full 500-area planetary base. The suffix indicates whether Weight (t) or Volume (m³) of Inputs (I) or Outputs (O) is the limiting bottleneck.';
 				} 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.';
+					th.dataset.tooltip = 'Click to sort.\n\nBreak Even: CapEx ÷ daily profit. Note that CapEx dynamically includes working capital (days of OpEx) to accurately reflect operational readiness.';
 				} else {
 					th.dataset.tooltip = 'Click to sort.';
 				}
@@ -188,19 +204,20 @@ async function render() {
 
 	const profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
 		const bases = p.area / 500;
-		let capex_val = p.capex[capexMetric] / bases;
 		const opex_val = p.opex[opexMetric] / bases;
 		const revenue_val = p.revenue[revenueMetric] / bases;
 		
-		// EXTREME DETAIL: If the user toggles the UI checkbox, we silently inject the precalculated
-		// ship capital expenditures into the active CapEx pipeline. This causes the break-even math
-		// to dynamically cascade and update instantly.
+		// EXTREME DETAIL: CapEx now completely absorbs the working capital (days of OpEx).
+		// Because we moved the addition directly into `capex_val`, it will be explicitly rendered 
+		// inside the CapEx column on the dashboard, making the break-even math visually apparent.
+		let capex_val = (p.capex[capexMetric] / bases) + (opex_val * workingCapitalDays);
+		
 		if (includeShips) {
 			capex_val += p.ship_capex_per_base;
 		}
 
 		const profit_per_base = revenue_val - opex_val;
-		const break_even = profit_per_base > 0 ? (capex_val + 3 * opex_val) / profit_per_base : Infinity;
+		const break_even = profit_per_base > 0 ? capex_val / profit_per_base : Infinity;
 		
 		return { ...p, capex_val, opex_val, revenue_val, profit_per_base, break_even };
 	});
@@ -223,15 +240,17 @@ async function render() {
 		const volumeRatio = p.output_per_day / p.average_traded_7d;
 		const tr = document.createElement('tr');
 		
+		// EXTREME DETAIL: We replaced all formatDecimal/formatWhole calls with formatSigFig.
+		// Additionally, we append the newly generated bottleneck string onto the Logistics cell.
 		tr.innerHTML = `
 			<td>${p.outputs.map(o => o.ticker).join(', ')}</td>
 			<td>${expertise[p.expertise]}</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, 3_000_000, 400_000)}">${formatWhole(p.capex_val)}</td>
-			<td style="color: ${color(p.opex_val, 400_000, 10_000)}">${formatWhole(p.opex_val)}</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>
+			<td style="color: ${color(p.profit_per_base, 0, 150000)}">${formatSigFig(p.profit_per_base)}</td>
+			<td><span style="color: ${color(p.break_even, 30, 3)}">${formatSigFig(p.break_even)}</span>d</td>
+			<td style="color: ${color(p.capex_val, 3_000_000, 400_000)}">${formatSigFig(p.capex_val)}</td>
+			<td style="color: ${color(p.opex_val, 400_000, 10_000)}">${formatSigFig(p.opex_val)}</td>
+			<td style="color: ${color(p.logistics_per_base, 1000, 100)}">${formatSigFig(p.logistics_per_base)} ${p.logistics_bottleneck}</td>
+			<td style="color: ${color(p.market_capacity_base, 0.04, 1)}">${formatSigFig(p.market_capacity_base)}</td>
 		`;
 
 		const output = tr.querySelector('td')!;
@@ -243,19 +262,19 @@ async function render() {
 		profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, runs_per_base) + '\n\n' +
 			formatMatPrices(p.input_costs, opexMetric, runs_per_base) + '\n' +
 			'+ worker consumables\n\n' +
-			`(${formatWhole(p.revenue_val)} - ${formatWhole(p.opex_val)}) = ${formatDecimal(p.profit_per_base)}`;
+			`(${formatSigFig(p.revenue_val)} - ${formatSigFig(p.opex_val)}) = ${formatSigFig(p.profit_per_base)}`;
 
-		// EXTREME DETAIL: To provide absolute transparency, if the ship toggle is active,
-		// we inject a dedicated tooltip into the CapEx cell specifically. This proves to the user 
-		// exactly how many fractional ships were assumed and how much they cost.
+		// EXTREME DETAIL: Because working capital is now part of CapEx, we break down
+		// the constituent components in the tooltip so the math is transparent.
 		const capexCell = tr.querySelectorAll('td')[4];
-		capexCell.dataset.tooltip = `Base Construction: ${formatWhole(p.capex[capexMetric] / (p.area / 500))}`;
+		capexCell.dataset.tooltip = `Base Construction: ${formatSigFig(p.capex[capexMetric] / (p.area / 500))}`;
+		capexCell.dataset.tooltip += `\nWorking Capital (${workingCapitalDays} days): ${formatSigFig(p.opex_val * workingCapitalDays)}`;
 		if (includeShips) {
-			capexCell.dataset.tooltip += `\nShip CapEx: ${formatWhole(p.ship_capex_per_base)} (${formatDecimal(p.ship_capex_per_base / 800_000)} ships)`;
+			capexCell.dataset.tooltip += `\nShip CapEx: ${formatSigFig(p.ship_capex_per_base)} (${formatSigFig(p.ship_capex_per_base / 800_000)} ships)`;
 		}
 
 		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 / 500))} produced/day/base = ${formatDecimal(p.market_capacity_base)} equivalent bases`;
+		marketCell.dataset.tooltip = `Market Capacity: ${formatSigFig(p.average_traded_7d)} traded/day ÷ ${formatSigFig(p.output_per_day / (p.area / 500))} produced/day/base = ${formatSigFig(p.market_capacity_base)} equivalent bases`;
 
 		tbody.appendChild(tr);
 	}
@@ -276,6 +295,7 @@ function saveState() {
 	localStorage.setItem('roi-opex-metric', opexMetric);
 	localStorage.setItem('roi-revenue-metric', revenueMetric);
 	localStorage.setItem('roi-include-ships', includeShips.toString());
+	localStorage.setItem('roi-working-capital', workingCapitalDays.toString());
 }
 
 function color(n: number, low: number, high: number): string {
@@ -287,7 +307,7 @@ function formatMatPrices(matPrices: MatPrice[], metric: MetricType, runs_per_day
 	return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
 		const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
 		const daily_amount = amount * runs_per_day;
-		return `${ticker}: ${formatDecimal(daily_amount)} × ${formatDecimal(val)} = ${formatWhole(daily_amount * val)}`;
+		return `${ticker}: ${formatSigFig(daily_amount)} × ${formatSigFig(val)} = ${formatSigFig(daily_amount * val)}`;
 	}).join('\n');
 }
 
@@ -316,6 +336,7 @@ interface Profit {
 	input_costs: MatPrice[]
 	runs_per_day: number
 	logistics_per_base: number
+	logistics_bottleneck: string // Extracted from backend
 	output_per_day: number
 	average_traded_7d: number
 	market_capacity_base: number

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 207 - 116
www/roi_ai1.json


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 207 - 116
www/roi_ci1.json


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 206 - 116
www/roi_ic1.json


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 206 - 117
www/roi_nc1.json


Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно