diff --git a/README.md b/README.md index cea1b25..3cea990 100644 --- a/README.md +++ b/README.md @@ -182,8 +182,12 @@ The detail lives in dedicated documents: stampers that fleet needs, drawn as a live schematic of the machine park with the stamper line (lathe, Pheenix galvanics, Sibert prep) feeding it, each station counted from the galvanics cycle times; - every assumption is editable, with its source, and a scenario can be - shared by link + plus the money a year: revenue (standard, specials, title services), + PVC, labels, sleeves, jackets, cartons, lacquers, plating chemistry, + electricity and steam, loaded labour, overheads, EBITDA, depreciation + and payback, with the revenue model's adverse, central and favourable + cases and USD, THB or EUR; every assumption is editable, with its + source, and a scenario can be shared by link - [Test site](site/README.md): `site/` builds https://cnxvinyl.lol/, the internal test site with demo pages from `docs/` (the plant operation page first) and any static site we want to share by link; Gitea Actions diff --git a/docs/finance/revenue-model.md b/docs/finance/revenue-model.md index d5c6dd9..e319b6e 100644 --- a/docs/finance/revenue-model.md +++ b/docs/finance/revenue-model.md @@ -523,7 +523,10 @@ logistics sit in overheads below. ## Operating picture at full capacity -Built from the components above rather than asserted. Three scenarios, +Built from the components above rather than asserted. The +[press fleet calculator](../fleet-calculator.html) runs the same P&L for +any fleet, output and specials share, with energy and headcount computed +from the fleet and these three cases as presets. Three scenarios, each internally consistent: adverse pairs low volume with the low price and high cost, favourable does the opposite, central takes midpoints. Title-services contribution assumes 60–70% after lacquer blanks, styli diff --git a/docs/fleet-calculator.html b/docs/fleet-calculator.html index ef13eb9..c7def95 100644 --- a/docs/fleet-calculator.html +++ b/docs/fleet-calculator.html @@ -52,6 +52,9 @@ document.documentElement.setAttribute("data-theme",t);})(); .pk-pipe { fill: none; stroke-width: 3; stroke-dasharray: 8 5; opacity: 0.85; } .pk-steam { stroke: #e2462a; } .pk-cool { stroke: #2f7de1; } .pk-hyd { stroke: #d48600; } .pk-air { stroke: #8a97b0; } .pk-pow { stroke: #c9a800; } + tr.tot td { font-weight: 600; border-top: 1px solid var(--stroke); } + td.neg { color: var(--bad); } + #ecoBar div { min-width: 0; } .themebtn { position: absolute; top: 24px; right: 20px; background: var(--panel); color: var(--fg); border: 1px solid var(--stroke); border-radius: 5px; padding: 6px 12px; font: inherit; font-size: 14px; cursor: pointer; @@ -150,7 +153,7 @@ document.documentElement.setAttribute("data-theme",t);})(); .grid > div { min-width: 0; } .tscroll { overflow-x: auto; } .tscroll table { min-width: 560px; } - #ladT { min-width: 860px; } + #ladT { min-width: 1040px; }
@@ -215,6 +218,26 @@ document.documentElement.setAttribute("data-theme",t);})(); +The price and cost ends of the revenue model; editing a figure makes it your own case.
+ + + +Central is the revenue model's midpoint. The revenue model itself calls + that case optimistic: its EBITDA sits well above the 15 to 25 % pressing plants earn, because the cost + table still misses indirect labour, inbound freight, inventory, effluent and sales commission. Read the + adverse case as the honest one until those lines exist. Prices are a Western benchmark; Thai domestic + invoices run far higher (650 to 950 THB all-in at 300 units).
+Peaks add up. Steam, cooling and air are summed at each machine's peak, as Newbilt states them. A real plant staggers cycles, so the boiler and chiller can be sized lower once a utility engineer has the cycle data.
-Not scaled here: mastering (1 to 2), QC (1 to 2), maintenance (2) and admin, sales and - logistics (3 to 5), from scale.md; cover for leave and sickness; the boiler fuel and the PV - share of the power.
+Money. Prices, per-record costs, wages, overheads and depreciation come from + finance/revenue-model.md, capex from finance/total-investment.md (the start fleet plan plus each + Duplex system or Classic added or removed), the electricity tariff and PV from scale.md, the biomass + heat price from biomass.md. Electricity and steam follow the fleet: machines, chiller and compressors + at the hours run, steam from the presses' running time, plus an estimated base load for the rest of + the plant. Lacquer blanks, shipping cartons and the cutting engineer's wage have no figure on file + and are estimates. Missing as the revenue model lists: indirect labour, freight, inventory, effluent, + commission, extra galvanics units' capex.
+Not scaled here: admin, sales and logistics (in the overheads line); cover for leave + and sickness.
Sources in the CNX Vinyl planning repository: equipment/pressing-line.md,
equipment/newbilt/ (Duplex site requirements V14, offer 240620-04, 2026 flyers),
equipment/galvanics.md (cycle times per step; the stamper line counts each station from them,
@@ -307,6 +350,7 @@ document.documentElement.setAttribute("data-theme",t);})();
const S0 = {
mode: "target", target: 1000000, spec: 20, grow: "classic", dMin: 2, cMin: 1,
dFleet: 2, cFleet: 1, shifts: 2, hrs: 8, days: 300, eff: 65, rateD: 3, rateC: 2, specF: 100,
+ priceStd: 3.8, premSpec: 1.15, titleFee: 1155, cur: "USD",
};
// [group, id, label, value, unit, kind, source]
const AS = [];
@@ -375,6 +419,46 @@ grp("Mastering, galvanics and stamper prep", [
["vspMin", "Centre punch per stamper (Sibert VSP)", 4, "min", "est", "galvanics.md: 3 to 5 min, estimate"],
["vscMin", "Coin per stamper (Sibert VSC)", 2, "min", "est", "galvanics.md: 1 to 2 min, estimate"],
]);
+grp("Money", [
+ ["thbUsd", "THB per USD", 33, "THB", "doc", "revenue-model.md and total-investment.md, FX of 5 August 2026"],
+ ["usdEur", "USD per EUR", 1.155, "USD", "doc", "revenue-model.md"],
+ ["overheads", "Cash overheads a year", 330000, "USD", "doc", "revenue-model.md: 200 to 450k (admin, sales, insurance, facility, IT, fees, marketing)"],
+ ["capexBase", "Capex, start fleet plan", 6800000, "USD", "doc", "total-investment.md: 4.7 to 8.9M full vision; added scope not yet rolled up"],
+ ["capexD", "Capex per Duplex system beyond two", 265000, "USD", "doc", "total-investment.md: system 2 ~230 to 300k, estimate"],
+ ["capexC", "Capex per Classic beyond one", 375000, "USD", "doc", "total-investment.md: ~290 to 460k, no Newbilt price on file"],
+ ["deprYears", "Depreciation period", 12.5, "years", "doc", "revenue-model.md: 10 to 15 years"],
+]);
+grp("Materials and packaging", [
+ ["pvcPrice", "PVC compound", 2.3, "USD/kg", "doc", "revenue-model.md: 1.75 to 2.90 USD/kg record grade"],
+ ["labelCost", "Labels, a pair", 0.06, "USD", "doc", "revenue-model.md: 0.03 to 0.09"],
+ ["sleeveCost", "Inner sleeve", 0.13, "USD", "doc", "revenue-model.md: 0.09 to 0.17, poly-lined"],
+ ["jacketCost", "Jacket (printed carton)", 0.66, "USD", "doc", "revenue-model.md: 0.40 to 0.92, local carton partners"],
+ ["shrinkCost", "Shrink wrap", 0.025, "USD", "doc", "revenue-model.md: 0.02 to 0.03"],
+ ["cartonCost", "Shipping cartons and pallets, a record", 0.04, "USD", "est", "Estimate: a 25-record shipping carton at about 1 USD"],
+ ["maintCost", "Maintenance, spares, consumables, a record", 0.17, "USD", "doc", "revenue-model.md: 0.12 to 0.23"],
+]);
+grp("Stamper making", [
+ ["lacquerCost", "Lacquer blank and stylus wear, a side", 45, "USD", "est", "Estimate; no lacquer or stylus quote on file"],
+ ["chemPart", "Nickel, silver, chemistry, an electroformed part", 25, "USD", "doc", "revenue-model.md: 60 to 150 USD marginal per three-generation set, so 20 to 50 a part"],
+]);
+grp("Energy", [
+ ["kwhPrice", "Electricity, blended", 3.81, "THB/kWh", "doc", "scale.md: PEA TOU 3.2.2 at two shifts, before the demand charge (Ft September to December 2026)"],
+ ["demandPrice", "Demand charge", 132.93, "THB/kW a month", "doc", "PEA 3.2.2 (scale.md)"],
+ ["restKW", "Rest of the plant while running", 120, "kW", "est", "Estimate: HVAC, lighting, galvanics, finishing, offices; puts the start fleet in scale.md's 4.3 to 6.4M THB a year before PV"],
+ ["pvKWp", "Roof PV", 247, "kWp", "doc", "scale.md"],
+ ["pvYield", "PV yield, all self-consumed", 1400, "kWh/kWp", "doc", "scale.md: 1,400 to 1,500 kWh/kWp a year in northern Thailand"],
+ ["heatPrice", "Steam heat from biomass", 0.7, "THB/kWh", "doc", "biomass.md: 0.5 to 0.9 at 1.35 to 2 THB/kg delivered"],
+ ["steamKWh", "Heat per kg of steam", 0.68, "kWh/kg", "est", "Estimate: 10 bar saturated steam from ~80 °C feed water"],
+]);
+grp("Labour (THB a month a head, loaded ×1.3)", [
+ ["wageOp", "Press and finishing operator", 19500, "THB", "doc", "revenue-model.md labour build-up"],
+ ["wageQC", "QC", 26000, "THB", "doc", "revenue-model.md"],
+ ["wageMaint", "Maintenance", 32500, "THB", "doc", "revenue-model.md"],
+ ["wageGalv", "Galvanics", 32500, "THB", "doc", "revenue-model.md"],
+ ["wageCut", "Cutting engineer", 45000, "THB", "est", "Estimate; the revenue model counts mastering as title-services labour"],
+ ["qcPerShift", "QC inspectors per shift", 1, "", "est", "Estimate; scale.md: 1 to 2 QC"],
+ ["pressPerTech", "Presses per maintenance technician (at least 2)", 8, "", "est", "Estimate; scale.md: 2 in maintenance for the start fleet"],
+]);
grp("Planned plant, for the checks", [
["boiler", "Steam boiler", 400, "kg/h", "doc", "scale.md: sized for one Duplex system"],
["chiller", "Chiller", 250, "kW", "doc", "scale.md: sized for one Duplex system"],
@@ -382,6 +466,22 @@ grp("Planned plant, for the checks", [
]);
const A0 = Object.fromEntries(AS.map(r => [r[1], r[3]]));
const KIND = { vendor: "vendor", doc: "doc", est: "estimate", user: "your figure" };
+// revenue-model.md ranges: adverse pairs low price with high cost, favourable the reverse
+const CASES = {
+ adverse: { S: { priceStd: 3.4, premSpec: 0.6, titleFee: 580 },
+ A: { pvcPrice: 2.9, labelCost: 0.09, sleeveCost: 0.17, jacketCost: 0.92, shrinkCost: 0.03, maintCost: 0.23, overheads: 450000, capexBase: 8900000, heatPrice: 0.9, deprYears: 10 } },
+ central: { S: { priceStd: 3.8, premSpec: 1.15, titleFee: 1155 },
+ A: { pvcPrice: 2.3, labelCost: 0.06, sleeveCost: 0.13, jacketCost: 0.66, shrinkCost: 0.025, maintCost: 0.17, overheads: 330000, capexBase: 6800000, heatPrice: 0.7, deprYears: 12.5 } },
+ favourable: { S: { priceStd: 4.25, premSpec: 1.7, titleFee: 1730 },
+ A: { pvcPrice: 1.75, labelCost: 0.03, sleeveCost: 0.09, jacketCost: 0.4, shrinkCost: 0.02, maintCost: 0.12, overheads: 200000, capexBase: 4700000, heatPrice: 0.5, deprYears: 15 } },
+};
+function caseNow() {
+ for (const k in CASES) {
+ const c = CASES[k];
+ if (Object.keys(c.S).every(x => S[x] === c.S[x]) && Object.keys(c.A).every(x => A[x] === c.A[x])) return k;
+ }
+ return "";
+}
const LADDER = [
["Start fleet (current plan)", 2, 1], ["Step 1", 2, 3], ["Step 2", 2, 5], ["Step 3", 2, 8],
["Full hall, Classic-led", 2, 11], ["Full hall, mixed", 4, 7], ["Full hall, specials-led", 6, 3],
@@ -476,6 +576,50 @@ function compute(D, C, X, b) {
trimT: X * (1 + A.reject / 100) * A.trim / 1e6,
};
}
+function headsOf(r) {
+ return {
+ ops: r.staff, qc: r.X > 0 ? Math.ceil(A.qcPerShift * S.shifts) : 0, galv: r.galv,
+ maint: r.P > 0 ? Math.max(2, Math.ceil(r.P / Math.max(1, A.pressPerTech))) : 0, cut: r.gst.lathe.n,
+ };
+}
+// Money a year in USD. Lines: [group, label, USD, basis].
+function econ(r, b) {
+ const fx = A.thbUsd > 0 ? A.thbUsd : 33, t2u = v => v / fx;
+ const X = r.X, pressed = X * (1 + A.reject / 100), st = X - r.sp, L = [];
+ const add = (g, l, v, s) => L.push([g, l, v, s]);
+ const revStd = st * S.priceStd, revSpec = r.sp * (S.priceStd + S.premSpec), revTitle = r.titles * S.titleFee;
+ const rev = revStd + revSpec + revTitle;
+ add("mat", "PVC compound", r.compound * 1000 * A.pvcPrice, `${f0(r.compound)} t at ${f2(A.pvcPrice)} USD/kg incl. rejects; trim back as regrind`);
+ add("mat", "Labels", pressed * A.labelCost, "a printed pair per pressed record");
+ add("pack", "Inner sleeves", X * A.sleeveCost, "");
+ add("pack", "Jackets (printed cartons)", X * A.jacketCost, "local carton partners");
+ add("pack", "Shrink wrap", X * A.shrinkCost, "");
+ add("pack", "Shipping cartons and pallets", X * A.cartonCost, "estimate");
+ add("maint", "Maintenance, spares, consumables", X * A.maintCost, "");
+ add("metal", "Lacquer blanks and styli", r.titles * 2 * A.lacquerCost, `${f0(r.titles * 2)} lacquer sides`);
+ add("metal", "Nickel, silver, plating chemistry", r.partsYr * A.chemPart, `${f0(r.partsYr)} electroformed parts`);
+ const cap = capacityOf(r.D, r.C, b), use = cap > 0 ? Math.min(1, X / cap) : 0;
+ const runH = b.hoursYr * S.eff / 100 * use; // press running hours a year
+ const steamT = r.P * (A.steamLo + A.steamHi) / 2 * runH / 1000;
+ const heatMWh = steamT * A.steamKWh;
+ const elecMWh = X > 0 ? r.mwh + heatMWh / Math.max(0.5, A.cop) + (r.compKW + A.restKW) * b.hoursYr / 1000 : 0;
+ const pvMWh = Math.min(elecMWh, A.pvKWp * A.pvYield / 1000);
+ const demandKW = X > 0 ? r.kw + r.chillerKW + r.compKW + A.restKW : 0;
+ add("energy", "Electricity", t2u((elecMWh - pvMWh) * 1000 * A.kwhPrice + demandKW * A.demandPrice * 12),
+ `${f0(elecMWh - pvMWh)} MWh from the grid after ${f0(pvMWh)} MWh roof PV; ~${f0(demandKW)} kW demand`);
+ add("energy", "Steam heat (biomass)", t2u(heatMWh * 1000 * A.heatPrice), `${f0(steamT)} t of steam, ${f0(heatMWh)} MWh of heat`);
+ const h = headsOf(r), wage = (n, w) => t2u(n * w * 12), ppl = n => n + (n === 1 ? " person" : " people");
+ add("lab", "Press operators and finishing", wage(h.ops, A.wageOp), ppl(h.ops));
+ add("lab", "QC", wage(h.qc, A.wageQC), ppl(h.qc));
+ add("lab", "Maintenance", wage(h.maint, A.wageMaint), ppl(h.maint));
+ add("lab", "Galvanics", wage(h.galv, A.wageGalv), ppl(h.galv));
+ add("lab", "Cutting engineers", wage(h.cut, A.wageCut), ppl(h.cut));
+ const sum = gs => L.filter(l => gs.includes(l[0])).reduce((a, l) => a + l[2], 0);
+ const direct = L.reduce((a, l) => a + l[2], 0), contrib = rev - direct, ebitda = contrib - A.overheads;
+ const capex = A.capexBase + (r.D - 2) * A.capexD + (r.C - 1) * A.capexC, depr = capex / Math.max(1, A.deprYears);
+ return { rev, revStd, revSpec, revTitle, L, sum, direct, contrib, ebitda, capex, depr, op: ebitda - depr,
+ payback: ebitda > 0 ? capex / ebitda : Infinity, heads: h };
+}
function scenario() {
const b = basis(S);
let D, C, X, cap;
@@ -495,6 +639,14 @@ const nf = d => new Intl.NumberFormat("en-US", { maximumFractionDigits: d, minim
const f0 = x => nf(0).format(Math.round(x));
const f1 = x => nf(1).format(x);
const big = x => x >= 1e6 ? nf(2).format(x / 1e6) + " M" : f0(x);
+const f2 = x => nf(2).format(x);
+const fix2 = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+const cv = usd => S.cur === "THB" ? usd * A.thbUsd : S.cur === "EUR" ? usd / Math.max(0.01, A.usdEur) : usd;
+function money(usd) {
+ const v = cv(usd), a = Math.abs(v);
+ return (a >= 1e6 ? nf(2).format(v / 1e6) + " M" : a >= 1e4 ? f0(v / 1e3) + " k" : f0(v)) + " " + S.cur;
+}
+const unit = usd => fix2.format(cv(usd)) + " " + S.cur;
const pct = x => Math.round(x * 100) + " %";
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
const $ = id => document.getElementById(id);
@@ -506,7 +658,9 @@ function render() {
$("specV").textContent = S.spec + " %";
$("targetBox").hidden = S.mode !== "target";
$("fleetBox").hidden = S.mode !== "fleet";
- for (const g of ["mode", "grow"]) for (const bt of $(g).children) bt.classList.toggle("on", bt.dataset.v === S[g]);
+ for (const g of ["mode", "grow", "cur"]) for (const bt of $(g).children) bt.classList.toggle("on", bt.dataset.v === S[g]);
+ const cs = caseNow();
+ for (const bt of $("case").children) bt.classList.toggle("on", bt.dataset.v === cs);
$("fleetK").innerHTML =
kpi(r.D, `Duplex systems
${2 * r.D} HRP 12 presses, ${r.D} EX 50, ${r.D} EX 35, ${r.D} trimmers`, "d") +
@@ -552,6 +706,46 @@ function render() {
`