Bläddra i källkod

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 2 veckor sedan
förälder
incheckning
3d6caf9bca
2 ändrade filer med 80 tillägg och 36 borttagningar
  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)
 	
-	# 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
-	ship_capex_per_base = normalized_logistics_per_base * 800_000
 
 	bottlenecks = [
 		(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,
 			average_traded_7d=average_traded_7d,
 			market_capacity_base=market_capacity_base,
-			ship_capex_per_base=ship_capex_per_base,
 			hq_costs=hq_costs)
 
 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]
 	runs_per_day: float
 	logistics_per_base: float
-	normalized_logistics_per_base: float # Explicitly exported for TS mapping
+	normalized_logistics_per_base: float 
 	logistics_bottleneck: str 
 	output_per_day: float
 	average_traded_7d: float
 	market_capacity_base: float
-	ship_capex_per_base: float
 	hq_costs: dict[str, dict[str, float]]
 
 	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 opexMetric: MetricType = (localStorage.getItem('roi-opex-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 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);
 
 async function render() {
@@ -73,6 +78,7 @@ async function render() {
 	if (!metricControlsInitialized) {
 		const controls = document.createElement('div');
 		controls.style.marginBottom = '15px';
+		// EXTREME DETAIL: Swapped the checkbox and number inputs for Round Trip (hrs) and the Dropdown Select.
 		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>
@@ -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>
 			</label>
 			<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 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 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>
 				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('opex-metric') as HTMLSelectElement).value = opexMetric;
 		(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('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('capex-metric')!.addEventListener('change', (e) => {
@@ -119,16 +138,16 @@ async function render() {
 			revenueMetric = (e.target as HTMLSelectElement).value as MetricType;
 			render();
 		});
-		document.getElementById('include-ships')!.addEventListener('change', (e) => {
-			includeShips = (e.target as HTMLInputElement).checked;
-			render();
-		});
 		document.getElementById('show-negative')!.addEventListener('change', (e) => {
 			showNegativeProfit = (e.target as HTMLInputElement).checked;
 			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) => {
-			workingCapitalDays = parseInt((e.target as HTMLInputElement).value, 10);
+			workingCapitalOption = (e.target as HTMLSelectElement).value;
 			render();
 		});
 		document.getElementById('target-permit')!.addEventListener('change', (e) => {
@@ -150,13 +169,12 @@ async function render() {
 				th.style.cursor = 'pointer';
 				th.title = ''; 
 				
-				// EXTREME DETAIL: Updated the tooltip legend to explain that 100.0% is universally good.
 				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.\n(Percentiles rank 100.0% as the most desirable outcome, i.e., highest profit or lowest cost.)';
 				} else if (keys[i] === 'capex_val') {
 					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') {
 					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.';
@@ -218,12 +236,26 @@ async function render() {
 		return true;
 	});
 
+	const dynamicOpEx = workingCapitalOption === 'dynamic';
+
 	let profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
 		const bases = p.area / 500;
 		const opex_val = p.opex[opexMetric] / 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;
 		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 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) {
@@ -273,9 +322,6 @@ async function render() {
 	for (const p of profitsWithMetrics) {
 		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 pctBreak = getPercentile(p.break_even, arrBreak, true);
 		const pctCapex = getPercentile(p.capex_val, arrCapex, true);
@@ -305,15 +351,17 @@ async function render() {
 			'+ worker consumables\n\n' +
 			`(${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];
 		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) {
 			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];
@@ -327,8 +375,6 @@ async function render() {
 	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 {
 	if (sortedArr.length < 2) return "100.0%";
 	let less = 0;
@@ -355,9 +401,9 @@ function saveState() {
 	localStorage.setItem('roi-capex-metric', capexMetric);
 	localStorage.setItem('roi-opex-metric', opexMetric);
 	localStorage.setItem('roi-revenue-metric', revenueMetric);
-	localStorage.setItem('roi-include-ships', includeShips.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());
 }
 
@@ -404,7 +450,6 @@ interface Profit {
 	output_per_day: number
 	average_traded_7d: number
 	market_capacity_base: number
-	ship_capex_per_base: number
 	hq_costs: Record<string, Metrics>
 }
 
@@ -416,6 +461,9 @@ interface ProfitWithMetrics extends Profit {
 	profit_per_base: number;
 	break_even: 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 {