Ver Fonte

Add_Numerical_Percentiles_and_Logistics_Normalization

Implemented universal percentile tracking and strictly normalized the logistics sorting algorithms.

        PURPOSE:
        To provide immediate comparative context to absolute financial metrics. By tagging every cell with a percentile `(0.00 - 1.00)` relative to the currently active UI view, users can instantly identify top-tier and bottom-tier recipes without manually scanning the entire array. Furthermore, the previous logistics sorting logic evaluated "raw amount" (e.g., 2000t vs 900m³), which failed to account for asymmetrical ship capacity limits. Normalizing this metric ensures that 1000m³ is correctly evaluated as mathematically identical to 3000t (1 full ship) for both array sorting and percentile generation.

        IMPLEMENTATION DETAILS:
        - `roi.py`: Extracted `normalized_logistics_per_base` directly into the `Profit` dataclass.
        - `ts/roi.ts`: Migrated the `logistics` array sort target away from the raw output variable to target the newly exposed `normalized_logistics_per_base`. Implemented a `localStorage` migration catch to ensure users with cached states do not crash.
        - `ts/roi.ts`: Built a `getPercentile()` helper function that calculates simple numerical rank relative to array length.
        - `ts/roi.ts`: Extracted 6 distinct sorted arrays immediately following the `.filter()` and `.map()` iterations. This guarantees that recipes hidden by UI toggles (like "low volume") are entirely excluded from the percentile population base, ensuring mathematical accuracy.
        - `ts/roi.ts`: Modified the HTML `<tr>` string generator to interpolate the calculated percentiles wrapped in a `<span style="opacity: 0.6;">` tag. This creates a visual hierarchy, ensuring the 3-sig-fig absolute metrics remain prominent while the percentile acts as supplemental metadata.
Thomas Knott há 2 semanas atrás
pai
commit
a8358675af
2 ficheiros alterados com 67 adições e 22 exclusões
  1. 9 9
      roi.py
  2. 58 13
      ts/roi.ts

+ 9 - 9
roi.py

@@ -12,8 +12,6 @@ def main() -> None:
 	materials: dict[str, Material] = {m['ticker']: m for m in cache.get('https://api.prunplanner.org/data/materials/')}
 	raw_prices: list[RawPrice] = cache.get('https://refined-prun.github.io/refined-prices/all.json')
 	
-	# EXTREME DETAIL: We execute the "Open Source Heist" here. We pull the static JSON 
-	# directly from PRUNplanner's GitHub repository. 
 	hq_levels_raw = cache.get('https://raw.githubusercontent.com/PRUNplanner/frontend/ec2ab897624121186f7de8e6c2e28ebf292f4432/src/features/hq_upgrade_calculator/hq_levels.json')
 	
 	for cx in ['AI1', 'CI1', 'IC1', 'NC1']:
@@ -29,9 +27,6 @@ def calc_for_cx(cx: str, recipes: typing.Collection[Recipe], buildings: typing.M
 		if p['ExchangeCode'] == cx
 	}
 	
-	# EXTREME DETAIL: We pre-calculate the VWAP, Bid, and Ask prices for every HQ level.
-	# This ensures the frontend doesn't have to do any heavy lifting, and the HQ CapEx
-	# naturally inherits the user's active price-metric permutation from the dropdowns!
 	hq_costs: dict[str, dict[str, float]] = {}
 	for level_str, mats in hq_levels_raw.items():
 		cost = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
@@ -129,8 +124,11 @@ def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_ar
 
 	runs_per_base = runs_per_day / (area / 500)
 	
-	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
+	# 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³).
+	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)'),
@@ -151,6 +149,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,
+			normalized_logistics_per_base=normalized_logistics_per_base,
 			logistics_bottleneck=logistics_bottleneck,
 			output_per_day=output_per_day,
 			average_traded_7d=average_traded_7d,
@@ -252,12 +251,13 @@ class Profit:
 	input_costs: typing.Collection[MatPrice]
 	runs_per_day: float
 	logistics_per_base: float
-	logistics_bottleneck: str
+	normalized_logistics_per_base: float # Explicitly exported for TS mapping
+	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]] # Added the HQ pricing dictionary
+	hq_costs: dict[str, dict[str, float]]
 
 	def __lt__(self, other: Profit) -> bool:
 		bases_a = self.area / 500

+ 58 - 13
ts/roi.ts

@@ -44,8 +44,13 @@ if (localStorage.getItem('roi-low-volume')) lowVolume.checked = localStorage.get
 
 let savedBuilding = localStorage.getItem('roi-building') || '';
 
-let currentSortKey: keyof ProfitWithMetrics | 'outputs' = (localStorage.getItem('roi-sort-key') as any) || 'break_even';
+// EXTREME DETAIL: Legacy cache migration. If the user had the old raw 'logistics_per_base' stored
+// as their active sort, we organically migrate them to the new normalized property.
+let storedSortKey = localStorage.getItem('roi-sort-key') as any;
+if (storedSortKey === 'logistics_per_base') storedSortKey = 'normalized_logistics_per_base';
+let currentSortKey: keyof ProfitWithMetrics | 'outputs' = storedSortKey || 'break_even';
 let currentSortAsc: boolean = localStorage.getItem('roi-sort-asc') !== 'false';
+
 let headersInitialized = false;
 let metricControlsInitialized = false;
 
@@ -68,8 +73,6 @@ async function render() {
 	if (!metricControlsInitialized) {
 		const controls = document.createElement('div');
 		controls.style.marginBottom = '15px';
-		// EXTREME DETAIL: Re-arranged the `<label>` blocks so the descriptive text precedes the input boxes,
-		// and updated "Target Permit" to "Permit Number" per the user's specification.
 		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>
@@ -129,9 +132,12 @@ async function render() {
 
 	if (!headersInitialized) {
 		const ths = document.querySelectorAll('th');
+		// EXTREME DETAIL: We swapped `logistics_per_base` for `normalized_logistics_per_base`.
+		// Clicking the Logistics header now intrinsically triggers a sort utilizing the ship fraction,
+		// ensuring that 1000m³ perfectly balances against 3000t.
 		const keys: (keyof ProfitWithMetrics | 'outputs')[] = [
 			'outputs', 'expertise', 'profit_per_base', 'break_even', 
-			'capex_val', 'opex_val', 'logistics_per_base', 'market_capacity_base'
+			'capex_val', 'opex_val', 'normalized_logistics_per_base', 'market_capacity_base'
 		];
 		
 		ths.forEach((th, i) => {
@@ -141,16 +147,16 @@ async function render() {
 				
 				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.';
+					th.dataset.tooltip = 'Click to sort.\n\nDaily profit scaled to a full 500-area planetary base.\n(Percentiles rank 0.00 as lowest numerical value to 1.00 as highest)';
 				} 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.';
 				} 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') {
+				} else if (keys[i] === 'normalized_logistics_per_base') {
 					th.textContent = 'Logistics/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.';
+					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.\nSorts and percentiles are strictly normalized based on ship capacity limits (3000t or 1000m³).';
 				} 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.';
@@ -198,6 +204,9 @@ async function render() {
 	
 	savedBuilding = ''; 
 
+	// EXTREME DETAIL: Because the percentile derivations evaluate the arrays *after* the filter executes,
+	// any row removed by the "low volume" constraint is automatically excluded from the 
+	// percentile population math, ensuring your rankings are entirely context-accurate.
 	const filteredProfits = profits.filter(p => {
 		const volumeRatio = p.output_per_day / p.average_traded_7d;
 		if (!lowVolume.checked && volumeRatio > 0.05) return false;
@@ -232,6 +241,16 @@ async function render() {
 		return { ...p, capex_val, opex_val, revenue_val, profit_per_base, break_even, hq_capex };
 	});
 
+	// EXTREME DETAIL: We extract 6 sorted arrays of our active numeric values post-mapping.
+	// Using a robust numeric sorting function protects against `NaN` crashes when dealing with `Infinity`.
+	const numSort = (a: number, b: number) => (a < b ? -1 : a > b ? 1 : 0);
+	const arrProfit = profitsWithMetrics.map(p => p.profit_per_base).sort(numSort);
+	const arrBreak = profitsWithMetrics.map(p => p.break_even).sort(numSort);
+	const arrCapex = profitsWithMetrics.map(p => p.capex_val).sort(numSort);
+	const arrOpex = profitsWithMetrics.map(p => p.opex_val).sort(numSort);
+	const arrLog = profitsWithMetrics.map(p => p.normalized_logistics_per_base).sort(numSort);
+	const arrCap = profitsWithMetrics.map(p => p.market_capacity_base).sort(numSort);
+
 	profitsWithMetrics.sort((a, b) => {
 		let valA: any = a[currentSortKey as keyof ProfitWithMetrics];
 		let valB: any = b[currentSortKey as keyof ProfitWithMetrics];
@@ -249,15 +268,27 @@ async function render() {
 	for (const p of profitsWithMetrics) {
 		const tr = document.createElement('tr');
 		
+		// Map the raw value to its percentile rank within the active filtered column.
+		const pctProfit = getPercentile(p.profit_per_base, arrProfit);
+		const pctBreak = getPercentile(p.break_even, arrBreak);
+		const pctCapex = getPercentile(p.capex_val, arrCapex);
+		const pctOpex = getPercentile(p.opex_val, arrOpex);
+		const pctLog = getPercentile(p.normalized_logistics_per_base, arrLog);
+		const pctCap = getPercentile(p.market_capacity_base, arrCap);
+		
+		// EXTREME DETAIL: We append the percentiles enclosed in a <small> tag.
+		// Reducing the opacity slightly creates visual hierarchy, allowing the 3-sig-fig
+		// metrics to remain the primary focal point of the table while the percentile acts as metadata.
+		// Note: The Logistics color mapping natively targets `normalized_logistics_per_base` (0.1 to 1.0 ship bounds).
 		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)}">${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>
+			<td style="color: ${color(p.profit_per_base, 0, 150000)}">${formatSigFig(p.profit_per_base)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctProfit})</span></td>
+			<td><span style="color: ${color(p.break_even, 30, 3)}">${formatSigFig(p.break_even)}</span>d <span style="font-size: 0.85em; opacity: 0.6;">(${pctBreak})</span></td>
+			<td style="color: ${color(p.capex_val, 3_000_000, 400_000)}">${formatSigFig(p.capex_val)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctCapex})</span></td>
+			<td style="color: ${color(p.opex_val, 400_000, 10_000)}">${formatSigFig(p.opex_val)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctOpex})</span></td>
+			<td style="color: ${color(p.normalized_logistics_per_base, 1.0, 0.1)}">${formatSigFig(p.logistics_per_base)} ${p.logistics_bottleneck} <span style="font-size: 0.85em; opacity: 0.6;">(${pctLog})</span></td>
+			<td style="color: ${color(p.market_capacity_base, 0.04, 1)}">${formatSigFig(p.market_capacity_base)} <span style="font-size: 0.85em; opacity: 0.6;">(${pctCap})</span></td>
 		`;
 
 		const output = tr.querySelector('td')!;
@@ -293,6 +324,19 @@ async function render() {
 	saveState();
 }
 
+// EXTREME DETAIL: O(N) simple percentile ranker. 
+// Locates how many array items are strictly less than the target value.
+// Bounding it against `sortedArr.length - 1` gracefully maps the output from 0.00 to 1.00.
+function getPercentile(val: number, sortedArr: number[]): string {
+	if (sortedArr.length < 2) return "1.00";
+	let less = 0;
+	for (let i = 0; i < sortedArr.length; i++) {
+		if (sortedArr[i] < val) less++;
+		else break; // Break early because array is guaranteed pre-sorted
+	}
+	return (less / (sortedArr.length - 1)).toFixed(2);
+}
+
 function saveState() {
 	localStorage.setItem('roi-cx', cxSelect.value);
 	localStorage.setItem('roi-expertise', expertiseSelect.value);
@@ -346,6 +390,7 @@ interface Profit {
 	input_costs: MatPrice[]
 	runs_per_day: number
 	logistics_per_base: number
+	normalized_logistics_per_base: number // Extracted from backend explicitly for sorting/percentiles
 	logistics_bottleneck: string 
 	output_per_day: number
 	average_traded_7d: number