diff --git a/src/PowderCoating.Application/Services/ShopCapabilityCalculator.cs b/src/PowderCoating.Application/Services/ShopCapabilityCalculator.cs
index a1a2f52..421bfb9 100644
--- a/src/PowderCoating.Application/Services/ShopCapabilityCalculator.cs
+++ b/src/PowderCoating.Application/Services/ShopCapabilityCalculator.cs
@@ -5,145 +5,165 @@ namespace PowderCoating.Application.Services;
///
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
-/// Used in two places: the AI photo quote prompt (so Claude reasons from real shop
-/// speeds) and the calculated-item wizard (to show a suggested blast time hint).
+/// Used by the AI photo quote prompt (so Claude reasons from real shop speeds)
+/// and the Company Settings live preview (so the UI always shows the same rate
+/// the AI will use — single formula path, no client-side duplication).
///
-/// Formula:
-/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier
+/// Both pressure pots and siphon cabinets are nozzle-primary: nozzle size
+/// determines throughput and CFM draw. CFM is not used in the rate formula.
///
-/// Base rates by CFM represent a pressure pot at #5 nozzle removing paint.
-/// All multipliers are relative to that baseline.
+/// Sources:
+/// Pressure pot rates — averaged from two industry standard abrasive blast
+/// cleaning reference tables.
+/// Siphon cabinet rates — industry reference table for siphon-fed cabinets.
+/// Substrate multipliers — relative removal difficulty vs. paint baseline.
///
public static class ShopCapabilityCalculator
{
- // ── Blast rate derivation ─────────────────────────────────────────────────
+ // ── Public entry points ────────────────────────────────────────────────────
///
- /// Returns the effective blast rate in sqft/hr.
- /// If is set, returns it directly.
- /// Otherwise derives from CFM, nozzle, setup type, and substrate.
- /// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
+ /// Returns the effective blast rate in sqft/hr for company-level operating costs.
+ /// BlastRateSqFtPerHourOverride bypasses the formula when set.
///
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
{
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
return costs.BlastRateSqFtPerHourOverride.Value;
- if (costs.CompressorCfm <= 0)
- return 0m;
-
- var baseRate = BaseByCfm(costs.CompressorCfm);
- var nozzle = NozzleMultiplier(costs.BlastNozzleSize);
- var setup = SetupMultiplier(costs.BlastSetupType);
- var substrate = SubstrateMultiplier(costs.PrimaryBlastSubstrate);
-
- return Math.Round(baseRate * nozzle * setup * substrate, 1);
+ return CalculateBlastRate(costs.BlastNozzleSize, costs.BlastSetupType, costs.PrimaryBlastSubstrate);
}
///
- /// Returns the effective blast rate in sqft/hr for a named .
- /// Identical logic to the overload — uses override if set,
- /// otherwise derives from the setup's equipment specs.
+ /// Returns the effective blast rate in sqft/hr for a named blast setup.
+ /// BlastRateSqFtPerHourOverride bypasses the formula when set.
///
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
{
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
return setup.BlastRateSqFtPerHourOverride.Value;
- if (setup.CompressorCfm <= 0)
- return 0m;
-
- var baseRate = BaseByCfm(setup.CompressorCfm);
- var nozzle = NozzleMultiplier(setup.BlastNozzleSize);
- var setupMult = SetupMultiplier(setup.SetupType);
- var substrate = SubstrateMultiplier(setup.PrimarySubstrate);
-
- return Math.Round(baseRate * nozzle * setupMult * substrate, 1);
+ return CalculateBlastRate(setup.BlastNozzleSize, setup.SetupType, setup.PrimarySubstrate);
}
///
/// Returns the effective coating application rate in sqft/hr.
- /// If override is set, returns it directly.
- /// Otherwise derives a sensible default from gun type.
+ /// Override bypasses the formula when set.
///
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
{
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
return costs.CoatingRateSqFtPerHourOverride.Value;
- // Corona and tribo guns are roughly similar on flat parts; tribo edges out on complex geometry.
- // Without more equipment data (voltage, gun model) we use a single reasonable default.
return costs.CoatingGunType switch
{
CoatingGunType.Corona => 40m,
- CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default
+ CoatingGunType.Tribo => 35m,
CoatingGunType.Both => 40m,
_ => 40m
};
}
///
- /// Returns default equipment field values for a given capability tier.
- /// Applied during Setup Wizard tier selection so the shop gets reasonable
- /// starting values even if they never visit the Quoting Calibration tab.
+ /// Returns default equipment field values for a given capability tier, applied
+ /// during Setup Wizard tier selection so new shops get reasonable starting values.
+ /// CFM defaults reflect typical compressor sizes for each tier; they appear in the
+ /// UI for reference but are not used in the rate formula.
///
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
TierDefaults(ShopCapabilityTier tier) => tier switch
{
- ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed),
- ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed),
- ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed),
- ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed),
- _ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed)
+ ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed),
+ ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 49m, 3, BlastSubstrateType.Mixed),
+ ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 90m, 4, BlastSubstrateType.Mixed),
+ ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 5, BlastSubstrateType.Mixed),
+ _ => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed)
};
- // ── Private helpers ───────────────────────────────────────────────────────
+ // ── Core formula (single path for all callers) ─────────────────────────────
///
- /// Base sqft/hr at a pressure pot, #5 nozzle, removing paint.
- /// Calibrated so that real-world examples produce expected results:
- /// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel)
- /// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel)
- /// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
- /// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
+ /// Nozzle-primary blast rate calculation. Nozzle size determines throughput;
+ /// setup type routes to the appropriate reference table; substrate adjusts for
+ /// removal difficulty. CFM is not used — it is a consequence of nozzle choice,
+ /// not an independent variable in throughput.
///
- private static decimal BaseByCfm(decimal cfm) => cfm switch
+ private static decimal CalculateBlastRate(int nozzle, BlastSetupType setupType, BlastSubstrateType substrate)
{
- < 10 => 5m,
- < 20 => 9m,
- < 40 => 15m,
- < 80 => 25m,
- < 120 => 35m,
- _ => 45m
+ var baseRate = setupType switch
+ {
+ BlastSetupType.PressurePot => PressurePotRateByNozzle(nozzle),
+ BlastSetupType.SiphonCabinet => SiphonCabinetRateByNozzle(nozzle),
+ // Siphon pot: open gravity feed, no enclosure penalty, ~80% of pressure pot
+ BlastSetupType.SiphonPot => Math.Round(PressurePotRateByNozzle(nozzle) * 0.80m, 1),
+ // Wet blasting: water-media mix reduces impact velocity, ~60% of dry pressure pot
+ BlastSetupType.WetBlasting => Math.Round(PressurePotRateByNozzle(nozzle) * 0.60m, 1),
+ _ => 0m
+ };
+
+ return Math.Round(baseRate * SubstrateMultiplier(substrate), 1);
+ }
+
+ ///
+ /// Midpoint cleaning rates for a pressure pot at adequate air supply, by nozzle size.
+ /// Averaged from two industry-standard abrasive blast cleaning reference tables.
+ /// #1 (1/16"): 20-35 sqft/hr avg → 20
+ /// #2 (1/8"): 40-60 sqft/hr avg → 40
+ /// #3 (3/16"): 60-85 sqft/hr avg → 75
+ /// #4 (1/4"): 90-110 sqft/hr avg → 115
+ /// #5 (5/16"): 130-160 sqft/hr avg → 175
+ /// #6 (3/8"): 180-230 sqft/hr avg → 245
+ /// #7 (7/16"): 240-300 sqft/hr avg → 325
+ /// #8 (1/2"): 320-400 sqft/hr avg → 430
+ ///
+ private static decimal PressurePotRateByNozzle(int nozzle) => nozzle switch
+ {
+ 1 => 20m,
+ 2 => 40m,
+ 3 => 75m,
+ 4 => 115m,
+ 5 => 175m,
+ 6 => 245m,
+ 7 => 325m,
+ 8 => 430m,
+ _ => 100m
};
- private static decimal NozzleMultiplier(int nozzle) => nozzle switch
+ ///
+ /// Midpoint cleaning rates for siphon-fed blast cabinets, by nozzle size.
+ /// Source: industry reference table for siphon cabinet production rates.
+ /// #1 (1/16"): 10-25 sqft/hr → 18
+ /// #2 (1/8"): 25-50 sqft/hr → 38
+ /// #3 (3/16"): 50-100 sqft/hr → 75
+ /// #4 (1/4"): 100-150 sqft/hr → 125
+ /// #5 (5/16"): 150-225 sqft/hr → 188
+ /// #6 (3/8"): 225-300 sqft/hr → 263
+ /// #7 (7/16"): 300-375 sqft/hr → 338
+ /// #8 (1/2"): 375-450 sqft/hr → 413
+ ///
+ private static decimal SiphonCabinetRateByNozzle(int nozzle) => nozzle switch
{
- 2 => 0.35m,
- 3 => 0.55m,
- 4 => 0.75m,
- 5 => 1.00m,
- 6 => 1.30m,
- 7 => 1.65m,
- 8 => 2.00m,
- _ => 1.00m
- };
-
- private static decimal SetupMultiplier(BlastSetupType setup) => setup switch
- {
- BlastSetupType.SiphonCabinet => 0.50m, // enclosed, low pressure, repositioning time
- BlastSetupType.SiphonPot => 0.70m,
- BlastSetupType.PressurePot => 1.00m, // baseline
- BlastSetupType.WetBlasting => 0.60m,
- _ => 1.00m
+ 1 => 18m,
+ 2 => 38m,
+ 3 => 75m,
+ 4 => 125m,
+ 5 => 188m,
+ 6 => 263m,
+ 7 => 338m,
+ 8 => 413m,
+ _ => 80m
};
+ ///
+ /// Adjustment for substrate removal difficulty relative to paint (baseline = 1.0).
+ /// Powder coat strips faster than paint; rust and scale requires multiple passes.
+ ///
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
{
- BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint
- BlastSubstrateType.Paint => 1.00m, // baseline
+ BlastSubstrateType.PowderCoat => 1.25m,
+ BlastSubstrateType.Paint => 1.00m,
BlastSubstrateType.Mixed => 0.90m,
- BlastSubstrateType.RustAndScale => 0.70m, // requires more passes
+ BlastSubstrateType.RustAndScale => 0.70m,
_ => 0.90m
};
}