Procházet zdrojové kódy

Add_Dynamic_OpEx_and_Little's_Law_Ship_Calculations

Refactored ship capacity planning and implemented context-aware dynamic OpEx capital requirements.

        PURPOSE:
        To achieve maximum fidelity in supply chain modeling. Ship fleets are constrained by round-trip duration, which dictates exactly how many discrete vessels must be purchased to sustain continuous daily throughput (Little's Law). Furthermore, inventory working capital (OpEx Days) can be tied natively to the shipping bottleneck. If a bulky product mandates a shipment every 2 days, the company mathematically only needs 2 days of OpEx buffer, preventing capital over-allocation.

        IMPLEMENTATION DETAILS:
        - `roi.py`: Completely stripped out the static `ship_capex_per_base` pre-calculation, as its derivation is now fully handled in real-time by the frontend.
        - `ts/roi.ts`: Replaced the "Include Ships" checkbox and "Days OpEx" numerical input with a "Round Trip (hrs)" input and a "Days OpEx" dropdown.
        - `ts/roi.ts`: The dropdown includes `<option value="dynamic">Max for Shipment (dynamic)</option>` alongside standard integer days `0` through `30`.
        - `ts/roi.ts`: Within the `.map()` derivation block, if the `dynamicOpEx` parameter is active, the script evaluates `1 / p.normalized_logistics_per_base` to derive the exact number of days a recipe takes to fill a standard freighter, scaling the working capital CapEx requirement dynamically row-by-row.
        - `ts/roi.ts`: Implemented `const shipsNeeded = p.normalized_logistics_per_base * (roundTripHours / 24);` to evaluate the exact fleet capacity required for continuous throughput, multiplying by 800,000 to assign the CapEx. This effectively deletes the capital cost if the user inputs `0` hours.
        - `ts/roi.ts`: Updated the CapEx column tooltip to output the specific evaluated `activeWorkingCapitalDays`, `activeShipCapex`, and `shipsNeeded` metrics to maintain visual verification of the dynamic math.
Thomas Knott před 2 týdny
rodič
revize
469c02f6ff
2 změnil soubory, kde provedl 80 přidání a 36 odebrání
  1. 3 7
      roi.py
  2. 77 29
      ts/roi.ts

+ 3 - 7
roi.py

@@ -124,11 +124,9 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 
 
 	runs_per_base = runs_per_day / (area / 500)
 	runs_per_base = runs_per_day / (area / 500)
 	
 	
-	# EXTREME DETAIL: We extract the normalized fraction of a ship required.
-	# We pass this to the frontend as `normalized_logistics_per_base` so TS can use it 
-	# to accurately sort and calculate percentiles, regardless of unit type (t vs m³).
+	# EXTREME DETAIL: We export the normalized daily ship fraction, but we have deleted the static 
+	# ship_capex calculation since the frontend UI now completely controls the Round Trip Time parameter.
 	normalized_logistics_per_base = max(in_w / 3000, in_v / 1000, out_w / 3000, out_v / 1000) * runs_per_base
 	normalized_logistics_per_base = max(in_w / 3000, in_v / 1000, out_w / 3000, out_v / 1000) * runs_per_base
-	ship_capex_per_base = normalized_logistics_per_base * 800_000
 
 
 	bottlenecks = [
 	bottlenecks = [
 		(in_w, 't (I)'),
 		(in_w, 't (I)'),
@@ -154,7 +152,6 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 			output_per_day=output_per_day,
 			output_per_day=output_per_day,
 			average_traded_7d=average_traded_7d,
 			average_traded_7d=average_traded_7d,
 			market_capacity_base=market_capacity_base,
 			market_capacity_base=market_capacity_base,
-			ship_capex_per_base=ship_capex_per_base,
 			hq_costs=hq_costs)
 			hq_costs=hq_costs)
 
 
 def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> dict[str, float]:
 def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> dict[str, float]:
@@ -251,12 +248,11 @@ class Profit:
 	input_costs: typing.Collection[MatPrice]
 	input_costs: typing.Collection[MatPrice]
 	runs_per_day: float
 	runs_per_day: float
 	logistics_per_base: float
 	logistics_per_base: float
-	normalized_logistics_per_base: float # Explicitly exported for TS mapping
+	normalized_logistics_per_base: float 
 	logistics_bottleneck: str 
 	logistics_bottleneck: str 
 	output_per_day: float
 	output_per_day: float
 	average_traded_7d: float
 	average_traded_7d: float
 	market_capacity_base: float
 	market_capacity_base: float
-	ship_capex_per_base: float
 	hq_costs: dict[str, dict[str, float]]
 	hq_costs: dict[str, dict[str, float]]
 
 
 	def __lt__(self, other: Profit) -> bool:
 	def __lt__(self, other: Profit) -> bool:

+ 77 - 29
ts/roi.ts

@@ -55,10 +55,15 @@ let metricControlsInitialized = false;
 let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as MetricType) || 'vwap';
 let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as MetricType) || 'vwap';
 let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricType) || 'vwap';
 let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricType) || 'vwap';
 let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
 let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
-let includeShips: boolean = localStorage.getItem('roi-include-ships') === 'true';
 
 
+// EXTREME DETAIL: Replaced `includeShips` with `roundTripHours`. 
+// If the user inputs 0, ship CapEx evaluates to 0.
+let roundTripHours: number = parseInt(localStorage.getItem('roi-round-trip') || '0', 10);
 let showNegativeProfit: boolean = localStorage.getItem('roi-show-negative') !== 'false';
 let showNegativeProfit: boolean = localStorage.getItem('roi-show-negative') !== 'false';
-let workingCapitalDays: number = parseInt(localStorage.getItem('roi-working-capital') || '3', 10);
+
+// EXTREME DETAIL: Replaced numerical `workingCapitalDays` state with a string `workingCapitalOption`
+// to natively support the 'dynamic' Dropdown value alongside hardcoded integers.
+let workingCapitalOption: string = localStorage.getItem('roi-working-capital-opt') || 'dynamic';
 let targetPermit: number = parseInt(localStorage.getItem('roi-target-permit') || '2', 10);
 let targetPermit: number = parseInt(localStorage.getItem('roi-target-permit') || '2', 10);
 
 
 async function render() {
 async function render() {
@@ -73,6 +78,7 @@ async function render() {
 	if (!metricControlsInitialized) {
 	if (!metricControlsInitialized) {
 		const controls = document.createElement('div');
 		const controls = document.createElement('div');
 		controls.style.marginBottom = '15px';
 		controls.style.marginBottom = '15px';
+		// EXTREME DETAIL: Swapped the checkbox and number inputs for Round Trip (hrs) and the Dropdown Select.
 		controls.innerHTML = `
 		controls.innerHTML = `
 			<label style="margin-right: 15px;">CapEx Price: 
 			<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>
 				<select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
@@ -84,13 +90,26 @@ async function render() {
 				<select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
 				<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;">
 			<label style="margin-right: 15px;">
-				<input type="checkbox" id="include-ships"> Include Ship CapEx
+				<input type="checkbox" id="show-negative"> Show Negative Profit
 			</label>
 			</label>
 			<label style="margin-right: 15px;">
 			<label style="margin-right: 15px;">
-				<input type="checkbox" id="show-negative"> Show Negative Profit
+				Round Trip (hrs): <input type="number" id="round-trip-hours" min="0" step="1" style="width: 50px;">
 			</label>
 			</label>
 			<label style="margin-right: 15px;">
 			<label style="margin-right: 15px;">
-				Days OpEx: <input type="number" id="working-capital" min="0" step="1" style="width: 50px;">
+				Days OpEx: 
+				<select id="working-capital">
+					<option value="dynamic">Max for Shipment (dynamic)</option>
+					<option value="0">0</option>
+					<option value="1">1</option>
+					<option value="2">2</option>
+					<option value="3">3</option>
+					<option value="4">4</option>
+					<option value="5">5</option>
+					<option value="6">6</option>
+					<option value="7">7</option>
+					<option value="14">14</option>
+					<option value="30">30</option>
+				</select>
 			</label>
 			</label>
 			<label>
 			<label>
 				Permit Number: <input type="number" id="target-permit" min="1" step="1" style="width: 50px;">
 				Permit Number: <input type="number" id="target-permit" min="1" step="1" style="width: 50px;">
@@ -102,9 +121,9 @@ async function render() {
 		(document.getElementById('capex-metric') as HTMLSelectElement).value = capexMetric;
 		(document.getElementById('capex-metric') as HTMLSelectElement).value = capexMetric;
 		(document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
 		(document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
 		(document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
 		(document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
-		(document.getElementById('include-ships') as HTMLInputElement).checked = includeShips;
 		(document.getElementById('show-negative') as HTMLInputElement).checked = showNegativeProfit;
 		(document.getElementById('show-negative') as HTMLInputElement).checked = showNegativeProfit;
-		(document.getElementById('working-capital') as HTMLInputElement).value = workingCapitalDays.toString();
+		(document.getElementById('round-trip-hours') as HTMLInputElement).value = roundTripHours.toString();
+		(document.getElementById('working-capital') as HTMLSelectElement).value = workingCapitalOption;
 		(document.getElementById('target-permit') as HTMLInputElement).value = targetPermit.toString();
 		(document.getElementById('target-permit') as HTMLInputElement).value = targetPermit.toString();
 
 
 		document.getElementById('capex-metric')!.addEventListener('change', (e) => {
 		document.getElementById('capex-metric')!.addEventListener('change', (e) => {
@@ -119,16 +138,16 @@ async function render() {
 			revenueMetric = (e.target as HTMLSelectElement).value as MetricType;
 			revenueMetric = (e.target as HTMLSelectElement).value as MetricType;
 			render();
 			render();
 		});
 		});
-		document.getElementById('include-ships')!.addEventListener('change', (e) => {
-			includeShips = (e.target as HTMLInputElement).checked;
-			render();
-		});
 		document.getElementById('show-negative')!.addEventListener('change', (e) => {
 		document.getElementById('show-negative')!.addEventListener('change', (e) => {
 			showNegativeProfit = (e.target as HTMLInputElement).checked;
 			showNegativeProfit = (e.target as HTMLInputElement).checked;
 			render();
 			render();
 		});
 		});
+		document.getElementById('round-trip-hours')!.addEventListener('change', (e) => {
+			roundTripHours = parseInt((e.target as HTMLInputElement).value, 10);
+			render();
+		});
 		document.getElementById('working-capital')!.addEventListener('change', (e) => {
 		document.getElementById('working-capital')!.addEventListener('change', (e) => {
-			workingCapitalDays = parseInt((e.target as HTMLInputElement).value, 10);
+			workingCapitalOption = (e.target as HTMLSelectElement).value;
 			render();
 			render();
 		});
 		});
 		document.getElementById('target-permit')!.addEventListener('change', (e) => {
 		document.getElementById('target-permit')!.addEventListener('change', (e) => {
@@ -150,13 +169,12 @@ async function render() {
 				th.style.cursor = 'pointer';
 				th.style.cursor = 'pointer';
 				th.title = ''; 
 				th.title = ''; 
 				
 				
-				// EXTREME DETAIL: Updated the tooltip legend to explain that 100.0% is universally good.
 				if (keys[i] === 'profit_per_base') {
 				if (keys[i] === 'profit_per_base') {
 					th.textContent = 'Profit/Base';
 					th.textContent = 'Profit/Base';
 					th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.\n(Percentiles rank 100.0% as the most desirable outcome, i.e., highest profit or lowest cost.)';
 					th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.\n(Percentiles rank 100.0% as the most desirable outcome, i.e., highest profit or lowest cost.)';
 				} else if (keys[i] === 'capex_val') {
 				} else if (keys[i] === 'capex_val') {
 					th.textContent = 'CapEx/Base';
 					th.textContent = 'CapEx/Base';
-					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), optional HQ Upgrade materials for the target permit, and optional Ship CapEx.';
+					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), optional HQ Upgrade materials for the target permit, and Ship CapEx (set Round Trip hrs to 0 to exclude).';
 				} else if (keys[i] === 'opex_val') {
 				} else if (keys[i] === 'opex_val') {
 					th.textContent = 'OpEx/Base';
 					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.';
 					th.dataset.tooltip = 'Click to sort.\n\nDaily operational expenditure (input materials + worker consumables) scaled to a full 500-area planetary base.';
@@ -218,12 +236,26 @@ async function render() {
 		return true;
 		return true;
 	});
 	});
 
 
+	const dynamicOpEx = workingCapitalOption === 'dynamic';
+
 	let profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
 	let profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
 		const bases = p.area / 500;
 		const bases = p.area / 500;
 		const opex_val = p.opex[opexMetric] / bases;
 		const opex_val = p.opex[opexMetric] / bases;
 		const revenue_val = p.revenue[revenueMetric] / bases;
 		const revenue_val = p.revenue[revenueMetric] / bases;
 		
 		
-		let capex_val = (p.capex[capexMetric] / bases) + (opex_val * workingCapitalDays);
+		// EXTREME DETAIL: We determine exactly how many OpEx days are required. 
+		// If 'dynamic' is selected, the required working capital scales perfectly inverse to the logistics
+		// footprint (e.g., if a base fills 0.5 ships a day, the max shipment interval is 1/0.5 = 2 days).
+		let activeWorkingCapitalDays = 0;
+		if (dynamicOpEx) {
+			activeWorkingCapitalDays = p.normalized_logistics_per_base > 0 
+				? 1 / p.normalized_logistics_per_base 
+				: 0;
+		} else {
+			activeWorkingCapitalDays = parseInt(workingCapitalOption, 10);
+		}
+		
+		let capex_val = (p.capex[capexMetric] / bases) + (opex_val * activeWorkingCapitalDays);
 		
 		
 		let hq_capex = 0;
 		let hq_capex = 0;
 		if (targetPermit >= 3) {
 		if (targetPermit >= 3) {
@@ -234,14 +266,31 @@ async function render() {
 			}
 			}
 		}
 		}
 
 
-		if (includeShips) {
-			capex_val += p.ship_capex_per_base;
+		// EXTREME DETAIL: Little's Law dictates that Inventory in transit = Throughput * Lead Time.
+		// Throughput = daily ship fraction. Lead Time = Round Trip Hours / 24.
+		// This generates the absolute fleet size required to prevent bottlenecks.
+		const shipsNeeded = p.normalized_logistics_per_base * (roundTripHours / 24);
+		const activeShipCapex = shipsNeeded * 800_000;
+		
+		if (roundTripHours > 0) {
+			capex_val += activeShipCapex;
 		}
 		}
 
 
 		const profit_per_base = revenue_val - opex_val;
 		const profit_per_base = revenue_val - opex_val;
 		const break_even = profit_per_base > 0 ? capex_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, hq_capex };
+		return { 
+			...p, 
+			capex_val, 
+			opex_val, 
+			revenue_val, 
+			profit_per_base, 
+			break_even, 
+			hq_capex,
+			activeWorkingCapitalDays,
+			activeShipCapex,
+			shipsNeeded
+		};
 	});
 	});
 
 
 	if (!showNegativeProfit) {
 	if (!showNegativeProfit) {
@@ -273,9 +322,6 @@ async function render() {
 	for (const p of profitsWithMetrics) {
 	for (const p of profitsWithMetrics) {
 		const tr = document.createElement('tr');
 		const tr = document.createElement('tr');
 		
 		
-		// EXTREME DETAIL: We explicitly route each column to its correct `invert` state.
-		// Profit and Market Cap use `false` (highest numerical value = 100%).
-		// Break Even, CapEx, OpEx, and Logistics use `true` (lowest numerical value = 100%).
 		const pctProfit = getPercentile(p.profit_per_base, arrProfit, false);
 		const pctProfit = getPercentile(p.profit_per_base, arrProfit, false);
 		const pctBreak = getPercentile(p.break_even, arrBreak, true);
 		const pctBreak = getPercentile(p.break_even, arrBreak, true);
 		const pctCapex = getPercentile(p.capex_val, arrCapex, true);
 		const pctCapex = getPercentile(p.capex_val, arrCapex, true);
@@ -305,15 +351,17 @@ async function render() {
 			'+ worker consumables\n\n' +
 			'+ worker consumables\n\n' +
 			`(${formatSigFig(p.revenue_val)} - ${formatSigFig(p.opex_val)}) = ${formatSigFig(p.profit_per_base)}`;
 			`(${formatSigFig(p.revenue_val)} - ${formatSigFig(p.opex_val)}) = ${formatSigFig(p.profit_per_base)}`;
 
 
+		// EXTREME DETAIL: Updated the tooltip to track the dynamically assigned working capital 
+		// and the newly calculated Ship fleet size derived from the user's explicit Round Trip Time.
 		const capexCell = tr.querySelectorAll('td')[4];
 		const capexCell = tr.querySelectorAll('td')[4];
 		capexCell.dataset.tooltip = `Base Construction: ${formatSigFig(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)}`;
+		capexCell.dataset.tooltip += `\nWorking Capital (${formatSigFig(p.activeWorkingCapitalDays)} days): ${formatSigFig(p.opex_val * p.activeWorkingCapitalDays)}`;
 		
 		
 		if (p.hq_capex > 0) {
 		if (p.hq_capex > 0) {
 			capexCell.dataset.tooltip += `\nHQ Upgrade (Permit ${targetPermit}): ${formatSigFig(p.hq_capex)}`;
 			capexCell.dataset.tooltip += `\nHQ Upgrade (Permit ${targetPermit}): ${formatSigFig(p.hq_capex)}`;
 		}
 		}
-		if (includeShips) {
-			capexCell.dataset.tooltip += `\nShip CapEx: ${formatSigFig(p.ship_capex_per_base)} (${formatSigFig(p.ship_capex_per_base / 800_000)} ships)`;
+		if (roundTripHours > 0) {
+			capexCell.dataset.tooltip += `\nShip CapEx: ${formatSigFig(p.activeShipCapex)} (${formatSigFig(p.shipsNeeded)} ships)`;
 		}
 		}
 
 
 		const marketCell = tr.querySelectorAll('td')[7];
 		const marketCell = tr.querySelectorAll('td')[7];
@@ -327,8 +375,6 @@ async function render() {
 	saveState();
 	saveState();
 }
 }
 
 
-// EXTREME DETAIL: Added 'invert' boolean to flip logic for cost metrics.
-// Returns a heavily formatted string e.g., '99.0%'.
 function getPercentile(val: number, sortedArr: number[], invert: boolean = false): string {
 function getPercentile(val: number, sortedArr: number[], invert: boolean = false): string {
 	if (sortedArr.length < 2) return "100.0%";
 	if (sortedArr.length < 2) return "100.0%";
 	let less = 0;
 	let less = 0;
@@ -355,9 +401,9 @@ function saveState() {
 	localStorage.setItem('roi-capex-metric', capexMetric);
 	localStorage.setItem('roi-capex-metric', capexMetric);
 	localStorage.setItem('roi-opex-metric', opexMetric);
 	localStorage.setItem('roi-opex-metric', opexMetric);
 	localStorage.setItem('roi-revenue-metric', revenueMetric);
 	localStorage.setItem('roi-revenue-metric', revenueMetric);
-	localStorage.setItem('roi-include-ships', includeShips.toString());
 	localStorage.setItem('roi-show-negative', showNegativeProfit.toString());
 	localStorage.setItem('roi-show-negative', showNegativeProfit.toString());
-	localStorage.setItem('roi-working-capital', workingCapitalDays.toString());
+	localStorage.setItem('roi-round-trip', roundTripHours.toString());
+	localStorage.setItem('roi-working-capital-opt', workingCapitalOption);
 	localStorage.setItem('roi-target-permit', targetPermit.toString());
 	localStorage.setItem('roi-target-permit', targetPermit.toString());
 }
 }
 
 
@@ -404,7 +450,6 @@ interface Profit {
 	output_per_day: number
 	output_per_day: number
 	average_traded_7d: number
 	average_traded_7d: number
 	market_capacity_base: number
 	market_capacity_base: number
-	ship_capex_per_base: number
 	hq_costs: Record<string, Metrics>
 	hq_costs: Record<string, Metrics>
 }
 }
 
 
@@ -416,6 +461,9 @@ interface ProfitWithMetrics extends Profit {
 	profit_per_base: number;
 	profit_per_base: number;
 	break_even: number;
 	break_even: number;
 	hq_capex: number;
 	hq_capex: number;
+	activeWorkingCapitalDays: number; // Added to interface to pass to the tooltip generator
+	activeShipCapex: number; // Added to interface to pass to the tooltip generator
+	shipsNeeded: number; // Added to interface to pass to the tooltip generator
 }
 }
 
 
 interface MatPrice {
 interface MatPrice {