Files
cnx_vinyl/docs/fleet-calculator.html
T
grabowski 7af5d89dd0
Deploy test site / deploy (push) Successful in 44s
Steam and cooling plant for the five-press fleet; utilities re-based
New page docs/steam-cooling.html (site /demos/steam-cooling/): the
utility plant as a clickable process diagram. Steam side: raw water,
softener and dosing, feed tank with heat-pump feedwater recovery, feed
pumps, a bank of two 500 kg/h boilers (third for N+1 as an option),
separator and metering, optional accumulator, blowdown, the 10 bar
header with a valved, metered branch and trap per press, drip legs and
condensate return. Cooling side: Newbilt's press outlets are open (max.
2 bar), so returns drain to a warm tank, pass the heat pump, an optional
dry cooler and a plate heat exchanger to a cold buffer tank, and VFD
pumps feed the 20 to 25 C header at ~10 bar to every press, the
extruders and the central hydraulic oil cooler; the chiller loop
(three 250 kW air-cooled modules at 40 C ambient, fourth as option) stays
closed and clean. Galvanics cooling (12 to 20 C) is a separate circuit.
Operating cases (launch, Duplex system 1, five presses, one module down)
show demand against installed capacity; design basis with sources,
equipment schedule, RFQ questions. Static SVG, no per-frame work.

Design basis at Newbilt's per-machine figures: steam 750 to 900 kg/h,
cooling 652.5 kW (625 presses, 15 extruders, 12.5 hydraulics), 305 to
540 l/min circulating. Also found: Newbilt's 53 l/min at ~60 C out is
~140 kW, above its 125 kW peak; listed as a re-quote question.

Re-based in the same commit:
- scale.md: electrical table (chiller bank ~210 to 325 kW electrical,
  plant peak ~430 to 700 kW, ~950 to 1,330 kW with electric steam),
  grid 800 to 1,000 kVA (1,250 to 1,600 with electric steam, Schedule
  4 past 1,000 kW), tariff 4.5M to 6.8M THB/yr, battery ~1 h of chiller
  peak, boiler and chiller banks, biomass up to ~550 t/yr at 1.5 M
  records, utilities money line.
- sustainability.md, biomass.md: same figures; electric steam 5.2M to
  6.0M THB/yr mature, 1.6M to 1.8M early; fuel saving 4.0M to 5.4M.
- utilities CSV: boiler bank, chiller bank with HX and tanks, softener
  and loop treatment re-sized; baseline 23.98M-41.63M -> 30.07M-51.33M.
- total-investment.md: re-size as an added-scope row (+185k / +294k
  USD); headline 4.7M to 8.9M unchanged until the investment re-base.
- pressing-line, equipment-suppliers, facility-security, fleet
  calculator (boiler 1000 kg/h, chiller 750 kW), plant page detail
  texts; README, landing page, build.sh.
2026-10-14 09:58:27 +07:00

1108 lines
75 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>CNX Vinyl · press fleet calculator</title>
<script>
(function(){var t=null;try{t=localStorage.getItem("cnx-theme")}catch(e){}
if(t!=="light"&&t!=="dark")t=window.matchMedia&&matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";
document.documentElement.setAttribute("data-theme",t);})();
</script>
<style>
:root {
--bg: #0a0f1a; --panel: #0f1626; --panel2: #131c30; --stroke: #2a3a56;
--line: #1c2840; --fg: #d6deeb; --mute: #8a97b0; --accent: #3ddc84;
--warn: #ffb547; --bad: #ff6b6b; --dspec: #c58bff; --dstd: #6aa8ff;
--classic: #3ddc84;
}
:root { color-scheme: dark; --input: #0a1120; --flagw: #1b1a12; --flago: #0f1a14; --flagb: #1d1214; --cur: #12261c;
--bv: #9fd0ff; --bvb: #2c4c73; --bd: #b5e8c7; --bdb: #2b5a3d; --be: #ffd59a; --beb: #6b5426; --bu: #e2c4ff; --bub: #563a73; }
html[data-theme=light] {
color-scheme: light;
--bg: #f4f6fa; --panel: #ffffff; --panel2: #eef2f8; --stroke: #cfd8e6; --line: #e4e9f1;
--fg: #1b2533; --mute: #5b6880; --accent: #0e8f4f; --warn: #c27c00; --bad: #c62828;
--dspec: #9b5de5; --dstd: #2f7de1; --classic: #1fae68;
--input: #ffffff; --flagw: #fff6e0; --flago: #e8f6ee; --flagb: #fdeaea; --cur: #e1f3e8;
--bv: #1f5f9e; --bvb: #9cc0e6; --bd: #1e7a45; --bdb: #9fd3b3; --be: #8a5a00; --beb: #e3c27a; --bu: #6b3fa0; --bub: #c9a8ec;
}
:root { --pk-press: #2a3a56; --pk-pressS: #6d86b3; --pk-glyph: #3a4d70; }
html[data-theme=light] { --pk-press: #c7d1df; --pk-pressS: #8e9db4; --pk-glyph: #b8c4d6; }
#park { display: block; width: 100%; height: auto; min-width: 900px; }
#park text { font-family: inherit; }
.pk-box { fill: var(--panel2); stroke: var(--stroke); }
.pk-box.warn { stroke: var(--warn); stroke-width: 2; }
.pk-hall { fill: none; stroke: var(--stroke); stroke-width: 1.4; stroke-dasharray: 7 5; }
.pk-cell { fill: var(--panel); stroke: var(--stroke); }
.pk-press { fill: var(--pk-press); stroke: var(--pk-pressS); stroke-width: 1.2; }
.pk-col { fill: var(--pk-pressS); }
.pk-ex50 { fill: var(--dstd); } .pk-ex35 { fill: var(--dspec); }
.pk-op { fill: #e8b400; } .pk-trim { fill: var(--mute); } .pk-disc { fill: #111; stroke: var(--mute); }
.pk-glyph { fill: var(--pk-glyph); stroke: var(--pk-pressS); }
.pk-feed { stroke: var(--mute); stroke-width: 1; fill: none; stroke-dasharray: 2 3; }
.pk-t { fill: var(--fg); font-size: 12px; font-weight: 600; }
.pk-s { fill: var(--mute); font-size: 10.5px; }
.pk-v { fill: var(--fg); font-size: 15px; font-weight: 700; }
.pk-n { fill: var(--fg); font-size: 19px; font-weight: 700; }
.pk-lane { fill: var(--mute); font-size: 11px; letter-spacing: 0.12em; font-weight: 600; }
.pk-arrow { stroke: var(--mute); stroke-width: 1.6; fill: none; }
.pk-head { fill: var(--mute); }
.pk-util { fill: var(--line); } .pk-utilv { fill: var(--accent); } .pk-utilv.hi { fill: var(--warn); }
.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;
}
.themebtn:hover { border-color: var(--accent); }
header { position: relative; }
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
a { color: var(--accent); }
header, .wrap { max-width: 1320px; margin: 0 auto; padding: 0 20px; }
header { padding-top: 28px; }
h1 { margin: 0; font-size: 26px; letter-spacing: 0.02em; }
.tag {
display: inline-block; margin-top: 6px; padding: 2px 8px;
border: 1px solid var(--stroke); border-radius: 4px; color: var(--mute); font-size: 13px;
}
.lead { color: var(--mute); max-width: 920px; margin: 14px 0 22px; }
.grid { display: grid; grid-template-columns: 370px 1fr; gap: 18px; align-items: start; }
@media (max-width: 960px) { .grid { grid-template-columns: 1fr; } }
.card {
background: var(--panel); border: 1px solid var(--stroke); border-radius: 8px;
padding: 16px 18px; margin-bottom: 18px;
}
h2 {
font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--mute); margin: 0 0 12px;
}
.row { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin: 9px 0; }
.row > span { flex: 1; }
.row small, .hint { color: var(--mute); font-size: 12.5px; }
.hint { margin: 6px 0 0; }
input[type=number] {
width: 118px; background: var(--input); color: var(--fg); border: 1px solid var(--stroke);
border-radius: 4px; padding: 5px 7px; font: inherit; text-align: right;
font-variant-numeric: tabular-nums;
}
input[type=number]:focus { outline: none; border-color: var(--accent); }
input[type=range] { width: 100%; accent-color: var(--accent); }
.seg { display: flex; border: 1px solid var(--stroke); border-radius: 6px; overflow: hidden; margin: 6px 0 10px; }
.seg button {
flex: 1; background: transparent; color: var(--mute); border: 0; padding: 7px 8px;
font: inherit; font-size: 14px; cursor: pointer;
}
.seg button + button { border-left: 1px solid var(--stroke); }
.seg button.on { background: var(--panel2); color: var(--accent); }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.pair .row { margin: 4px 0; }
.pair input[type=number] { width: 70px; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; }
.kpi { background: var(--panel2); border: 1px solid var(--stroke); border-radius: 6px; padding: 10px 12px; }
.kpi b { display: block; font-size: 24px; font-variant-numeric: tabular-nums; }
.kpi span { color: var(--mute); font-size: 13px; }
.kpi.d b { color: var(--dstd); } .kpi.c b { color: var(--classic); }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: var(--mute); font-weight: 500; font-size: 12.5px; }
td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
tr.grp td {
color: var(--accent); font-size: 12px; text-transform: uppercase; letter-spacing: 0.07em;
padding-top: 14px; border-bottom-color: var(--stroke);
}
td.src { color: var(--mute); font-size: 12.5px; }
.flag { border-left: 3px solid var(--warn); background: var(--flagw); padding: 7px 10px; margin: 8px 0 0; font-size: 14px; }
.flag.ok { border-color: var(--accent); background: var(--flago); }
.flag.bad { border-color: var(--bad); background: var(--flagb); }
.bar { display: flex; height: 24px; border-radius: 4px; overflow: hidden; margin: 14px 0 8px; background: var(--panel2); }
.bar div { height: 100%; }
.legend { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 13.5px; color: var(--mute); }
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; }
#ladT tbody tr { cursor: pointer; }
#ladT tbody tr:hover td { background: var(--panel2); }
#ladT tbody tr.cur td { background: var(--cur); }
.badge {
display: inline-block; font-size: 11px; padding: 0 6px; border-radius: 3px;
border: 1px solid var(--stroke); color: var(--mute); white-space: nowrap;
}
.badge.vendor { color: var(--bv); border-color: var(--bvb); }
.badge.doc { color: var(--bd); border-color: var(--bdb); }
.badge.est { color: var(--be); border-color: var(--beb); }
.badge.user { color: var(--bu); border-color: var(--bub); }
#asT input[type=number] { width: 92px; }
#asT tr.chg input { border-color: var(--warn); }
.btns { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
.btns button {
background: var(--panel2); color: var(--fg); border: 1px solid var(--stroke); border-radius: 5px;
padding: 7px 12px; font: inherit; font-size: 14px; cursor: pointer;
}
.btns button:hover { border-color: var(--accent); }
.note { color: var(--mute); font-size: 13.5px; }
.note p { margin: 6px 0; }
footer { color: var(--mute); font-size: 13px; padding: 10px 20px 40px; max-width: 1320px; margin: 0 auto; }
[hidden] { display: none !important; }
.grid > div { min-width: 0; }
.tscroll { overflow-x: auto; }
.tscroll table { min-width: 560px; }
#ladT { min-width: 1040px; }
</style>
</head>
<body>
<header>
<button class="themebtn" id="themeBtn" type="button">Light mode</button>
<h1>Press fleet calculator</h1>
<span class="tag">planning estimates · not a quote</span>
<p class="lead">
Set the records you want a year and the share of splatter and other
specials. The calculator sizes the press fleet from Newbilt Duplex systems
(two HRP 12 presses, an EX 50 and an EX 35 colour extruder each) and Newbilt
Classic automatic presses, starting from the planned two Duplex systems and
one Classic. It then lists what that fleet needs: people, power, central
hydraulics, steam, cooling, compressed air, floor, compound and stampers.
Vendor figures are from Newbilt's Duplex site requirements and offer; the
rest are planning figures and estimates from the CNX Vinyl documents. Every
figure can be changed under Assumptions.
</p>
</header>
<div class="wrap grid">
<div>
<section class="card">
<h2>What you want</h2>
<div class="seg" id="mode">
<button data-v="target">From a target output</button>
<button data-v="fleet">From a fleet</button>
</div>
<div id="targetBox">
<label class="row"><span>Records per year</span><input type="number" id="target" min="0" step="50000"></label>
<input type="range" id="targetR" min="0" max="10000000" step="50000" aria-label="Records per year">
<div class="row" style="margin-top:12px"><span>Grow the fleet with</span></div>
<div class="seg" id="grow">
<button data-v="classic">Classics</button>
<button data-v="duplex">Duplex systems</button>
</div>
<div class="hint" style="margin-bottom:6px">Start fleet, never fewer than:</div>
<div class="pair">
<label class="row"><span>Duplex</span><input type="number" id="dMin" min="0" step="1"></label>
<label class="row"><span>Classic</span><input type="number" id="cMin" min="0" step="1"></label>
</div>
</div>
<div id="fleetBox" hidden>
<div class="pair">
<label class="row"><span>Duplex systems</span><input type="number" id="dFleet" min="0" step="1"></label>
<label class="row"><span>Classics</span><input type="number" id="cFleet" min="0" step="1"></label>
</div>
<p class="hint">The output is what this fleet makes at the set effectiveness and specials share.</p>
</div>
<label class="row" style="margin-top:14px"><span>Splatter and specials, share of output</span><b id="specV"></b></label>
<input type="range" id="spec" min="0" max="100" step="1" aria-label="Specials share">
<p class="hint">Splatter, multi-colour and effect records run only on the Duplex, with
its EX 35 colour extruder (Newbilt recommends the Duplex for splatter). Black and
single-colour records run on either press.</p>
</section>
<section class="card">
<h2>Operation</h2>
<label class="row"><span>Shifts per day</span><input type="number" id="shifts" min="1" max="3" step="1"></label>
<label class="row"><span>Hours per shift</span><input type="number" id="hrs" min="1" max="12" step="0.5"></label>
<label class="row"><span>Working days per year</span><input type="number" id="days" min="1" max="365" step="1"></label>
<label class="row"><span>Overall effectiveness, %<br><small>scale.md: 60 to 70 % (changeovers, stamper changes, QC, breaks)</small></span><input type="number" id="eff" min="1" max="100" step="1"></label>
</section>
<section class="card">
<h2>Prices and money</h2>
<div class="seg" id="case">
<button data-v="adverse">Adverse</button>
<button data-v="central">Central</button>
<button data-v="favourable">Favourable</button>
</div>
<p class="hint" style="margin-bottom:6px">The price and cost ends of the revenue model; editing a figure makes it your own case.</p>
<label class="row"><span>Standard record, USD<br><small>black or single colour; revenue model: 3.50 to 4.60 blended</small></span><input type="number" id="priceStd" min="0" step="0.05"></label>
<label class="row"><span>Specials premium, USD<br><small>splatter and effects: +0.60 to 1.70</small></span><input type="number" id="premSpec" min="0" step="0.05"></label>
<label class="row"><span>Title services, USD a title<br><small>mastering, cutting, stampers, test pressings: 580 to 1,730</small></span><input type="number" id="titleFee" min="0" step="5"></label>
<div class="row" style="margin-top:12px"><span>Show money in</span></div>
<div class="seg" id="cur">
<button data-v="USD">USD</button>
<button data-v="THB">THB</button>
<button data-v="EUR">EUR</button>
</div>
<p class="hint" id="priceHint"></p>
<div class="row" style="margin-top:12px"><span>BOI promotion</span></div>
<div class="seg" id="boi">
<button data-v="on">Promoted</button>
<button data-v="off">Not promoted</button>
</div>
<label class="row"><span>Records exported, %<br><small>exports: no duty on their raw material (BOI), VAT zero-rated</small></span><input type="number" id="exportShare" min="0" max="100" step="5"></label>
</section>
<section class="card">
<h2>Press rates</h2>
<label class="row"><span>Duplex system, records per minute<br><small>both presses together; 3 = 40 s per press cycle. Newbilt quotes about 30 s per press</small></span><input type="number" id="rateD" min="0" step="0.1"></label>
<label class="row"><span>Classic, records per minute<br><small>2 = 30 s cycle; Newbilt flyer: 28 s at 180 g</small></span><input type="number" id="rateC" min="0" step="0.1"></label>
<label class="row"><span>Specials run at, % of the standard rate<br><small>no vendor figure; lower it to test slower splatter runs</small></span><input type="number" id="specF" min="10" max="100" step="5"></label>
</section>
</div>
<div>
<section class="card">
<h2>Fleet</h2>
<div class="kpis" id="fleetK"></div>
<div id="flags"></div>
</section>
<section class="card">
<h2>Output and machine mix</h2>
<div class="kpis" id="outK"></div>
<div class="bar" id="bar"></div>
<div class="legend" id="legend"></div>
<div class="tscroll" style="margin-top:12px"><table id="mixT"></table></div>
</section>
<section class="card">
<h2>Money a year</h2>
<div class="kpis" id="ecoK"></div>
<div id="boiNote"></div>
<div class="bar" id="ecoBar" style="height:28px"></div>
<div class="legend" id="ecoLeg"></div>
<div class="tscroll" style="margin-top:12px"><table id="ecoT"></table></div>
<p class="note" style="margin-bottom:0">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).</p>
</section>
<section class="card">
<h2>What the fleet needs</h2>
<div class="tscroll"><table id="reqT"></table></div>
</section>
</div>
</div>
<div class="wrap">
<section class="card">
<h2>Machine park and stamper production</h2>
<p class="note" style="margin-top:0">The fleet above as a schematic, not to scale: each Duplex cell as
in the cell sketch (presses either side of the operator, EX 50 and EX 35 behind him, trimmer in
front), each Classic with its built-in extruder; above it the stamper line that feeds the presses,
sized from the cycle times in galvanics.md; below it the central utilities. Bars show how busy
each station is; an amber box exceeds the plant as planned today.</p>
<div class="tscroll"><svg id="park" viewBox="0 0 1200 600" role="img" aria-label="Machine park and stamper production schematic"></svg></div>
</section>
<section class="card">
<h2>From small to fully equipped</h2>
<p class="note" style="margin-top:0">Fleets from the start fleet to a full Payap press hall (about 870 m²:
the two Duplex bays plus the Classic and ten more Classic positions, or the same floor
with more Duplex systems), at the current settings. Click a row to load it.</p>
<div class="tscroll"><table id="ladT"></table></div>
</section>
<section class="card">
<h2>Assumptions</h2>
<p class="note" style="margin-top:0">Every result above comes from these figures. Changed values are
kept in the link, so a scenario can be shared with "Copy link".</p>
<div class="tscroll"><table id="asT"></table></div>
<div class="btns">
<button id="copy">Copy link to this scenario</button>
<button id="reset">Reset everything</button>
</div>
</section>
<section class="card note">
<h2>Basis and limits</h2>
<p><b>Classic utilities.</b> Newbilt has published no power, steam, cooling or weight figures for
the Classic, so it counts here as one HRP 12 press plus one extruder on the central hydraulics.
Cross-check from the Toolex Alpha leaflet the Classic is based on: 1.2 kg steam, 15 l cooling
water, 18 l air and 3.15 l hydraulic oil per cycle (about 144 kg/h of steam at 2 a minute),
2.18 × 1.30 m, 1,850 kg. These go into the re-quote questions in pressing-line.md.</p>
<p><b>EX 35.</b> Counted at the EX 50's electrical, air and water figures; it is the smaller
extruder, so this errs high.</p>
<p><b>Central hydraulics.</b> Newbilt's aggregate serves two presses; the per-press figures are
half of it, summed without any diversity. Whether the Classic can run from the shared plant
is an open question for Newbilt.</p>
<p><b>Peaks add up.</b> 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.</p>
<p><b>Money.</b> 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 (70 EUR each) and the cutting engineer (50,000 THB a month) are team
figures; shipping cartons have no figure on file and are an estimate. Missing as the revenue model
lists: indirect labour, freight, inventory, effluent, commission, styli, extra galvanics units' capex.</p>
<p><b>BOI.</b> The capex blocks in total-investment.md are vendor prices without Thai import duty or
VAT, so they already assume BOI promotion: machinery imported under it pays no duty (Investment
Promotion Act section 28) and, with the duty, no import VAT (Revenue Department, BOI OSOS FAQ).
"Not promoted" adds the duty on the imported machinery to the capex and shows the import VAT as cash
paid at the border, reclaimable as input tax. Raw material: no duty on compound for exported
records, 90 % off for domestic sales. Revenue is net of VAT (exports zero-rated, 7 % on domestic
sales passed on). Corporate income tax and BOI's CIT exemptions (solar, automation, R&D) are not
modelled.</p>
<p><b>Labour.</b> Base salaries per role (revenue-model.md, its loaded rates divided by 1.3; the cutting
engineer a team figure), plus the employer's 5 % social security up to the 17,500 THB ceiling, 25 %
for overtime, bonus and PPE, and the company's health care for every member of staff, office
included: group health insurance, a yearly check-up, and the risk-factor tests Thai law requires
for the press floor, galvanics and maintenance; and unified Engelbert Strauss workwear with the
company logo for everyone, shipped from Germany with Thai duty (worker-safety.md).</p>
<p><b>Not scaled here:</b> admin, sales and logistics wages (in the overheads line); cover for
leave and sickness.</p>
<p>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,
before remakes and test pressings), scale.md, finance/revenue-model.md, site-candidates.md (Payap scenario model).</p>
</section>
</div>
<footer>CNX Vinyl planning repository · docs/fleet-calculator.html · figures are planning estimates, October 2026.</footer>
<script>
"use strict";
// ---------------------------------------------------------------- inputs
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", boi: "on", exportShare: 70,
};
// [group, id, label, value, unit, kind, source]
const AS = [];
function grp(g, rows) { rows.forEach(r => AS.push([g].concat(r))); }
grp("Personnel", [
["opD", "Operators per Duplex system, per shift", 1, "", "doc", "Duplex cell layout: one operator in the middle of each cell (pressing-line.md)"],
["cPerAtt", "Classics one attendant looks after", 4, "", "est", "Assumption: Newbilt has not stated it"],
["finRate", "Finishing output per person", 200, "records/h", "est", "Inspection, sleeving, packing; in line with scale.md (4 to 6 press and finishing people per shift for the start fleet)"],
["galvRate", "Electroformed parts per galvanics operator", 20, "a day", "vendor", "OMS rule of thumb: one person runs about 20 stampers a day (galvanics.md)"],
]);
grp("Per press (HRP 12; a Classic counts as one)", [
["pressKVA", "Connected load", 1, "kVA", "vendor", "Newbilt Duplex site requirements 3.2.1"],
["pressKW", "Average power", 0.5, "kW", "vendor", "Site requirements 3.2.1"],
["steamLo", "Steam, low", 150, "kg/h", "vendor", "Site requirements 3.2.3: 150 to 180 kg/h, 10 bar, 180 °C"],
["steamHi", "Steam, high", 180, "kg/h", "vendor", "Site requirements 3.2.3"],
["coolPress", "Cooling capacity, peak", 125, "kW", "vendor", "Site requirements 3.2.2: up to 125 kW depending on cycle time"],
["waterTyp", "Cooling water, typical", 53, "l/min", "vendor", "Site requirements 3.2.2: about 53 l/min used"],
["waterPeak", "Cooling water, peak", 100, "l/min", "vendor", "Site requirements 3.2.2: up to 100 l/min"],
["airPress", "Compressed air", 6, "m³/h", "vendor", "Site requirements 3.2.3: 6 to 10 bar"],
]);
grp("Per extruder (EX 50; EX 35 and Classic extruder at the same figures)", [
["extKVA", "Connected load", 15, "kVA", "vendor", "Site requirements 3.3.1 (EX 35 and Classic: estimate)"],
["extKW", "Average power", 8, "kW", "vendor", "Site requirements 3.3.1"],
["extAir", "Compressed air", 2, "m³/h", "vendor", "Site requirements 3.3.2"],
["extWater", "Cooling water", 4, "l/min", "vendor", "Site requirements 3.3.3"],
["extCool", "Cooling capacity", 3, "kW", "vendor", "Site requirements 3.3.3: about 2 to 3 kW"],
]);
grp("Central hydraulics, per press (Newbilt's aggregate serves two)", [
["hydFlow", "Pump flow", 16, "l/min", "vendor", "Offer 240620-04: 32 l/min pump per aggregate"],
["hydMotor", "Installed motor power", 7.5, "kW", "vendor", "Offer 240620-04: 11/15 kW motor per aggregate"],
["hydKVA", "Connected load", 7.5, "kVA", "vendor", "Site requirements 3.4.1: 15 kVA per aggregate"],
["hydKW", "Average power", 6, "kW", "vendor", "Site requirements 3.4.1: 12 kW per aggregate"],
["hydOil", "Oil fill (HLP 46)", 100, "l", "vendor", "Site requirements 3.4.3: 200 l per aggregate, 250 l tank"],
["hydAcc", "Accumulator volume", 25, "l", "vendor", "Offer 240620-04: 50 l per aggregate, up to 200 bar"],
["hydWater", "Oil cooler water", 4, "l/min", "vendor", "Site requirements 3.4.2: about 8 l/min per aggregate"],
["hydCool", "Oil cooler capacity", 2.5, "kW", "vendor", "Site requirements 3.4.2: up to 5 kW per aggregate"],
]);
grp("Utilities to supply it", [
["cop", "Chiller COP", 4, "", "est", "Estimate for a water-cooled chiller"],
["compSpec", "Compressor specific power", 0.11, "kW per m³/h", "est", "Estimate: screw compressor at about 6.5 kW per m³/min"],
]);
grp("Floor", [
["floorD", "Press floor per Duplex system", 110, "m²", "doc", "Payap model bay, 9.5 × 11.4 m; Newbilt recommends about 200 m² including storage and side rooms"],
["floorC", "Press floor per Classic", 50, "m²", "est", "Payap model position, 5.5 × 9 m; pressing-line.md estimates 50 to 75 m²"],
]);
grp("Material", [
["weight", "Average record weight", 160, "g", "est", "Estimate across the 140 and 180 g formats"],
["trim", "Edge trim per record", 30, "g", "vendor", "Newbilt granulator flyer: 20 to 40 g per 12-inch record, back as regrind"],
["reject", "Rejects", 5, "%", "est", "Estimate; Newbilt claims at least 95 % yield for the Classic"],
]);
grp("Stampers", [
["runLen", "Records per title", 3000, "", "doc", "Revenue model: 2,500 to 3,300 records per title"],
["stLife", "Pressings per stamper", 1500, "", "doc", "galvanics.md: 1,000 to 2,000"],
]);
grp("Mastering, galvanics and stamper prep", [
["lathe", "Lacquer sides one lathe cuts a day", 8, "sides", "est", "Estimate: about 20 min cutting per side plus setup and checks, one shift"],
["galvEff", "Usable share of galvanics and prep hours", 80, "%", "est", "Estimate; OMS: 6 to 8 runs on 4 cells in a 7.5-hour day (galvanics.md). Galvanics runs the plant's shifts"],
["silverMin", "Silvering per lacquer (PASU)", 15, "min", "est", "galvanics.md: ~15 min, estimate"],
["preMin", "Pre-plate per master (PPPU, four at once)", 25, "min", "doc", "galvanics.md: 15 to 30 min"],
["fatherMin", "Father in a PDPU cell", 75, "min", "doc", "galvanics.md: ~1 h 15 min"],
["motherMin", "Mother in a PDPU cell", 70, "min", "doc", "galvanics.md: ~1 to 1 1/4 h"],
["stamperMin", "Stamper in a PDPU cell", 45, "min", "doc", "galvanics.md: ~45 min"],
["handleMin", "Loading, rinsing, unloading per part", 15, "min", "doc", "galvanics.md: ~15 min on top of deposition"],
["pmpbMin", "Separation, passivation, cleaning per part (PMPB)", 12, "min", "est", "galvanics.md: ~10 to 15 min, estimate"],
["vsfMin", "Back-finish per stamper (Sibert VSF)", 8, "min", "est", "galvanics.md: 5 to 10 min, estimate; Sibert publishes none"],
["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("BOI and imports", [
["impMach", "Imported machinery in the start-fleet capex", 2750000, "USD", "doc", "total-investment.md block midpoints at vendor prices: presses 1.0M, galvanics 485k, Sibert 250k, lathe system 435k, metrology 580k; added presses count as imported"],
["dutyMach", "Import duty on that machinery without BOI", 5, "%", "est", "Estimate: Thai MFN averages 4.1 % on HS 8477 plastics machinery (customs-compliance.ai, March 2026), 0 to 10 % across HS 84; set per line once the HS codes are known"],
["vatRate", "VAT", 7, "%", "doc", "Thai VAT; with BOI duty exemption the import VAT is exempt too (Revenue Department, BOI OSOS FAQ; boi.md)"],
["pvcImp", "Share of PVC compound imported", 100, "%", "est", "Estimate: record-grade compound from the specialist suppliers"],
["dutyPVC", "Import duty on PVC compound without BOI", 5, "%", "est", "Estimate; no Thai tariff line checked for record compound yet"],
["boiRawRed", "BOI duty reduction on raw material for domestic sales", 90, "%", "doc", "boi.md: exempt for export production (section 36(1)), up to 90 % off for domestic sales (section 30)"],
]);
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, one per side", 70, "EUR", "user", "Team figure, October 2026 (revenue-model.md); styli not included"],
["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.5 to 6.8M 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: base salaries, THB a month", [
["salOp", "Press and finishing operator", 15000, "THB", "doc", "revenue-model.md: ~19,500 loaded at ×1.3"],
["salQC", "QC", 20000, "THB", "doc", "revenue-model.md: ~26,000 loaded"],
["salMaint", "Maintenance", 25000, "THB", "doc", "revenue-model.md: ~32,500 loaded"],
["salGalv", "Galvanics", 25000, "THB", "doc", "revenue-model.md: ~32,500 loaded"],
["salCut", "Cutting engineer", 50000, "THB", "user", "Team figure, October 2026 (revenue-model.md)"],
["officeHeads", "Admin, sales and logistics staff", 4, "people", "doc", "scale.md: 3 to 5; their wages sit in the overheads, they count here for health care"],
["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("Labour: on-costs and health care", [
["ssRate", "Employer social security", 5, "% of salary", "doc", "Social Security Act, section 33: 5 % up to the wage ceiling (worker-safety.md)"],
["ssCap", "Social security wage ceiling", 17500, "THB a month", "doc", "Raised from 15,000 on 1 January 2026, so at most 875 THB a month (worker-safety.md)"],
["otherOn", "Overtime, bonus, PPE and other on-costs", 25, "% of salary", "doc", "With social security this is the revenue model's ×1.3 at 15,000 THB"],
["healthIns", "Group health insurance (inpatient and outpatient)", 15000, "THB a head a year", "doc", "worker-safety.md: AXA SME plans 9,394 to 18,006 THB at 25 staff (CheckDi, October 2026); local plans 8,000 to 12,000"],
["checkup", "Annual health check-up", 2500, "THB a head a year", "est", "Estimate for an age mix; Rajavej Chiang Mai 2026 programmes 1,290 to 5,990 THB by age (worker-safety.md)"],
["occCheck", "Risk-factor tests (hearing, lungs, metals)", 1500, "THB a head a year", "est", "Estimate; required yearly for press floor, galvanics and maintenance (Ministerial Regulation B.E. 2547, worker-safety.md)"],
]);
grp("Workwear: Engelbert Strauss for all staff", [
["wwProd", "Production kit, with logo", 338, "EUR", "doc", "worker-safety.md: 3 trousers e.s.motion 2020, 3 polos, 2 T-shirts, sweatshirt, S1 safety shoes; Strauss prices without German VAT, October 2026; embroidery ~5 EUR a piece (estimate)"],
["wwOffice", "Office kit, with logo", 127, "EUR", "doc", "worker-safety.md: 4 polo shirts and a softshell jacket"],
["wwProdYr", "Production kits a head a year", 1, "", "est", "Estimate: daily wear and washing"],
["wwOffYr", "Office kits a head a year", 0.5, "", "est", "Estimate"],
["wwFreight", "Shipping to Thailand", 15, "% of goods", "est", "Estimate; Strauss quotes shipping with the order confirmation"],
["wwDuty", "Thai import duty on workwear", 14, "%", "doc", "HS 6203 work trousers 13.8 % (customs-compliance.ai, March 2026); BOI does not cover clothing; import VAT reclaimable"],
]);
grp("Planned plant, for the checks", [
["boiler", "Steam boiler bank", 1000, "kg/h", "doc", "steam-cooling.html and scale.md: 2 × 500 kg/h modules (proposal, estimate)"],
["chiller", "Chiller bank", 750, "kW", "doc", "steam-cooling.html and scale.md: 3 × 250 kW air-cooled modules (proposal, estimate)"],
["hall", "Payap press hall", 870, "m²", "doc", "site-candidates.md, Payap scenario model"],
]);
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],
];
let S = { ...S0 }, A = { ...A0 };
// ---------------------------------------------------------------- model
function basis(S) {
const hoursYr = S.shifts * S.hrs * S.days, eff = S.eff / 100;
return {
hoursYr,
capD: Math.max(0, S.rateD) * 60 * hoursYr * eff,
capC: Math.max(0, S.rateC) * 60 * hoursYr * eff,
s: Math.min(1, Math.max(0, S.spec / 100)),
f: Math.min(1, Math.max(0.1, S.specF / 100)),
};
}
// Largest yearly output a fleet makes at specials share s: specials only on
// Duplex (at rate factor f), standard on Classics first, then on spare Duplex time.
function capacityOf(D, C, b) {
const x1 = (D * b.capD + C * b.capC) / (b.s / b.f + 1 - b.s);
const x2 = b.s > 0 ? D * b.capD * b.f / b.s : Infinity;
return Math.max(0, Math.min(x1, x2));
}
function sizeFleet(S, b) {
const T = Math.max(0, S.target), sp = T * b.s, st = T - sp;
const dEq = b.capD > 0 ? sp / (b.capD * b.f) : 0;
const dMin = Math.max(0, Math.round(S.dMin)), cMin = Math.max(0, Math.round(S.cMin));
let D, C;
if (b.capD <= 0) return { D: dMin, C: b.capC > 0 ? Math.max(cMin, Math.ceil(st / b.capC - 1e-9)) : cMin };
if (S.grow === "classic" && b.capC > 0) {
D = Math.max(dMin, Math.ceil(dEq - 1e-9));
const spare = (D - dEq) * b.capD;
C = Math.max(cMin, Math.ceil(Math.max(0, st - spare) / b.capC - 1e-9));
} else {
C = cMin;
const stC = Math.min(st, C * b.capC);
D = Math.max(dMin, Math.ceil(dEq + (st - stC) / b.capD - 1e-9));
}
return { D, C };
}
function compute(D, C, X, b) {
const sp = X * b.s, st = X - sp;
const stC = Math.min(st, C * b.capC), stD = st - stC;
const dUse = D > 0 && b.capD > 0 ? (sp / (b.capD * b.f) + stD / b.capD) / D : 0;
const cUse = C > 0 && b.capC > 0 ? stC / (C * b.capC) : 0;
const P = 2 * D + C, E = 2 * D + C; // presses; extruders (EX 50 + EX 35, Classic built in)
const perHour = b.hoursYr > 0 ? X / b.hoursYr : 0;
const att = C > 0 ? Math.ceil(C / Math.max(1, A.cPerAtt)) : 0;
const fin = X > 0 ? Math.ceil(perHour / Math.max(1, A.finRate)) : 0;
const perShift = D * A.opD + att + fin;
const perSide = Math.ceil(A.runLen / Math.max(1, A.stLife));
const partsTitle = 2 * (2 + perSide);
const titles = X / Math.max(1, A.runLen);
const partsYr = titles * partsTitle, partsDay = partsYr / Math.max(1, S.days);
// stamper production per working day, stations sized from galvanics.md cycle times
const tDay = titles / Math.max(1, S.days);
const flow = { titlesDay: tDay, lacq: 2 * tDay, fathers: 2 * tDay, mothers: 2 * tDay, stampers: 2 * perSide * tDay };
flow.parts = flow.fathers + flow.mothers + flow.stampers;
const avail = S.shifts * S.hrs * 60 * Math.max(1, A.galvEff) / 100;
const need = (dem, cap) => dem <= 0 || cap <= 0 ? { n: 0, u: 0 }
: (n => ({ n, u: dem / (n * cap) }))(Math.max(1, Math.ceil(dem / cap - 1e-9)));
const galv = {
lathe: need(flow.lacq, A.lathe),
pasu: need(flow.lacq * A.silverMin, avail),
pppu: need(flow.lacq * (A.preMin + A.handleMin) / 4, avail),
pdpu: need(flow.fathers * (A.fatherMin + A.handleMin) + flow.mothers * (A.motherMin + A.handleMin)
+ flow.stampers * (A.stamperMin + A.handleMin), 2 * avail),
pmpb: need(flow.parts * A.pmpbMin, avail),
vsf: need(flow.stampers * A.vsfMin, avail),
vsp: need(flow.stampers * A.vspMin, avail),
vsc: need(flow.stampers * A.vscMin, avail),
};
const coolPeak = P * A.coolPress + E * A.extCool + P * A.hydCool;
const air = P * A.airPress + E * A.extAir;
const kvaMach = P * A.pressKVA + E * A.extKVA, kwMach = P * A.pressKW + E * A.extKW;
const kvaHyd = P * A.hydKVA, kwHyd = P * A.hydKW;
return {
D, C, X, P, E, sp, st, stC, stD, dUse, cUse, perHour, att, fin, perShift,
staff: perShift * S.shifts, galv: partsDay > 0 ? Math.ceil(partsDay / Math.max(1, A.galvRate)) : 0,
perSide, partsTitle, titles, partsYr, partsDay, flow, gst: galv,
kvaMach, kwMach, kvaHyd, kwHyd, kva: kvaMach + kvaHyd, kw: kwMach + kwHyd,
mwh: (kwMach + kwHyd) * b.hoursYr / 1000,
chillerKW: coolPeak / Math.max(0.5, A.cop), compKW: air * A.compSpec,
steam: [P * A.steamLo, P * A.steamHi], coolPeak,
water: [P * A.waterTyp + E * A.extWater + P * A.hydWater, P * A.waterPeak + E * A.extWater + P * A.hydWater],
air, hyd: { flow: P * A.hydFlow, motor: P * A.hydMotor, oil: P * A.hydOil, acc: P * A.hydAcc },
floor: D * A.floorD + C * A.floorC,
compound: X * A.weight * (1 + A.reject / 100) / 1e6,
extKgH: perHour * (A.weight + A.trim) * (1 + A.reject / 100) / 1000,
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, boiOn = S.boi !== "off") {
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`);
const expSh = Math.min(100, Math.max(0, S.exportShare)) / 100;
const pvcImported = r.compound * 1000 * A.pvcPrice * A.pvcImp / 100;
const pvcDuty = pvcImported * A.dutyPVC / 100 * (boiOn ? (1 - expSh) * (1 - A.boiRawRed / 100) : 1);
add("mat", "Import duty on PVC compound", pvcDuty, boiOn
? `BOI: none on the ${pct(expSh)} exported, ${f0(A.boiRawRed)} % off the rest`
: `${f1(A.dutyPVC)} % on ${pct(A.pvcImp / 100)} imported compound, no BOI`);
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", r.titles * 2 * A.lacquerCost * A.usdEur, `${f0(r.titles * 2)} lacquers at ${f0(A.lacquerCost)} EUR; styli and recuts not included`);
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), ppl = n => n + (n === 1 ? " person" : " people");
let base = 0, ss = 0;
for (const [l, n, sal] of [["Press operators and finishing", h.ops, A.salOp], ["QC", h.qc, A.salQC],
["Maintenance", h.maint, A.salMaint], ["Galvanics", h.galv, A.salGalv], ["Cutting engineers", h.cut, A.salCut]]) {
add("lab", l, t2u(n * sal * 12), `${ppl(n)} at ${f0(sal)} THB a month`);
base += n * sal * 12; ss += n * Math.min(sal, A.ssCap) * A.ssRate / 100 * 12;
}
add("lab", "Employer social security", t2u(ss), `${f1(A.ssRate)} % of salary up to ${f0(A.ssCap)} THB a month`);
add("lab", "Overtime, bonus, PPE", t2u(base * A.otherOn / 100), `${f1(A.otherOn)} % of base salaries`);
h.prod = h.ops + h.qc + h.maint + h.galv + h.cut; h.office = Math.max(0, Math.round(A.officeHeads));
h.all = h.prod + h.office; h.exposed = h.ops + h.galv + h.maint;
add("lab", "Health insurance, all staff", t2u(h.all * A.healthIns), `${ppl(h.all)}, office included, at ${f0(A.healthIns)} THB a year`);
add("lab", "Health check-ups, all staff", t2u(h.all * A.checkup + h.exposed * A.occCheck),
`yearly for everyone, plus risk-factor tests for ${ppl(h.exposed)} on the press floor, in galvanics and maintenance`);
const wwEur = (h.prod * A.wwProd * A.wwProdYr + h.office * A.wwOffice * A.wwOffYr) * (1 + A.wwFreight / 100) * (1 + A.wwDuty / 100);
add("lab", "Workwear, Engelbert Strauss, all staff", wwEur * A.usdEur,
`${f1(h.prod * A.wwProdYr)} production and ${f1(h.office * A.wwOffYr)} office kits a year, shipping and Thai duty included`);
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 fleetDelta = (r.D - 2) * A.capexD + (r.C - 1) * A.capexC;
const imp = Math.max(0, A.impMach + fleetDelta);
const dutyM = boiOn ? 0 : imp * A.dutyMach / 100, vatM = boiOn ? 0 : (imp + dutyM) * A.vatRate / 100;
const capex = A.capexBase + fleetDelta + dutyM, 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, imp, dutyM, vatM, pvcDuty, boiOn };
}
function scenario() {
const b = basis(S);
let D, C, X, cap;
if (S.mode === "fleet") {
D = Math.max(0, Math.round(S.dFleet)); C = Math.max(0, Math.round(S.cFleet));
if (D === 0 && b.s > 0) { b.s = 0; b.noSpecials = true; }
cap = capacityOf(D, C, b); X = cap;
} else {
({ D, C } = sizeFleet(S, b));
cap = capacityOf(D, C, b); X = Math.min(Math.max(0, S.target), cap);
}
return { b, cap, r: compute(D, C, X, b) };
}
// ---------------------------------------------------------------- format
const nf = d => new Intl.NumberFormat("en-US", { maximumFractionDigits: d, minimumFractionDigits: 0 });
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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const $ = id => document.getElementById(id);
// ---------------------------------------------------------------- render
function kpi(v, l, cls) { return `<div class="kpi ${cls || ""}"><b>${v}</b><span>${l}</span></div>`; }
function render() {
const { b, cap, r } = scenario();
$("specV").textContent = S.spec + " %";
$("targetBox").hidden = S.mode !== "target";
$("fleetBox").hidden = S.mode !== "fleet";
for (const g of ["mode", "grow", "cur", "boi"]) 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<br>${2 * r.D} HRP 12 presses, ${r.D} EX 50, ${r.D} EX 35, ${r.D} trimmers`, "d") +
kpi(r.C, "Newbilt Classic<br>automatic presses", "c") +
kpi(r.P, "presses in total") +
kpi(r.perShift, `people at the presses and in finishing, per shift (${r.staff} on ${S.shifts} shift${S.shifts > 1 ? "s" : ""})`);
const flags = [];
if (b.noSpecials)
flags.push(["bad", "No Duplex system: this fleet cannot press splatter or specials, so the output shown is black and single colour only."]);
if (S.mode === "target" && S.target > cap + 1)
flags.push(["bad", `The fleet cannot reach ${big(S.target)} records: check the press rates.`]);
if (S.mode === "target" && b.s > 0 && r.D > Math.max(0, Math.round(S.dMin)) && S.grow === "classic")
flags.push(["warn", `Specials alone need ${r.D} Duplex systems at this share.`]);
if (r.steam[1] > A.boiler)
flags.push(["warn", `Steam up to ${f0(r.steam[1])} kg/h: the planned ${f0(A.boiler)} kg/h boiler covers ${pct(A.boiler / r.steam[1])} of the peak.`]);
if (r.coolPeak > A.chiller)
flags.push(["warn", `Cooling up to ${f0(r.coolPeak)} kW: the planned ${f0(A.chiller)} kW chiller covers ${pct(A.chiller / r.coolPeak)} of the peak.`]);
if (r.floor > A.hall)
flags.push(["warn", `About ${f0(r.floor)} m² of press floor: more than the ${f0(A.hall)} m² Payap press hall.`]);
else flags.push(["ok", `About ${f0(r.floor)} m² of press floor: fits the ${f0(A.hall)} m² Payap press hall (${pct(r.floor / A.hall)}).`]);
$("flags").innerHTML = flags.map(([c, t]) => `<div class="flag ${c}">${esc(t)}</div>`).join("");
const use = cap > 0 ? r.X / cap : 0;
$("outK").innerHTML =
kpi(big(r.X), S.mode === "target" ? "records a year (target)" : "records a year at this mix") +
kpi(big(cap), "fleet capacity a year") +
kpi(pct(use), "of capacity used") +
kpi(f0(r.X / Math.max(1, S.days)), "records per working day") +
kpi(f0(r.perHour), "records per operating hour");
const W = r.X > 0 ? r.X : 1;
$("bar").innerHTML =
`<div style="width:${100 * r.sp / W}%;background:var(--dspec)" title="Duplex, specials"></div>` +
`<div style="width:${100 * r.stD / W}%;background:var(--dstd)" title="Duplex, standard"></div>` +
`<div style="width:${100 * r.stC / W}%;background:var(--classic)" title="Classic, standard"></div>`;
$("legend").innerHTML =
`<span><i style="background:var(--dspec)"></i>Duplex, specials ${big(r.sp)} (${pct(r.sp / W)})</span>` +
`<span><i style="background:var(--dstd)"></i>Duplex, black and single colour ${big(r.stD)} (${pct(r.stD / W)})</span>` +
`<span><i style="background:var(--classic)"></i>Classic, black and single colour ${big(r.stC)} (${pct(r.stC / W)})</span>`;
$("mixT").innerHTML =
`<thead><tr><th>Machine</th><th class="n">Units</th><th class="n">Capacity a year</th><th class="n">Makes a year</th><th class="n">Utilisation</th><th class="n">Share of output</th></tr></thead><tbody>` +
`<tr><td>Duplex system (2 presses)</td><td class="n">${r.D}</td><td class="n">${big(r.D * b.capD)}</td><td class="n">${big(r.sp + r.stD)}</td><td class="n">${pct(r.dUse)}</td><td class="n">${pct((r.sp + r.stD) / W)}</td></tr>` +
`<tr><td>Classic</td><td class="n">${r.C}</td><td class="n">${big(r.C * b.capC)}</td><td class="n">${big(r.stC)}</td><td class="n">${pct(r.cUse)}</td><td class="n">${pct(r.stC / W)}</td></tr></tbody>`;
// ---- money
const e = econ(r, b), Xr = Math.max(1, r.X), perRec = usd => r.X > 0 ? unit(usd / Xr) : "–";
const blended = r.X > 0 ? (e.revStd + e.revSpec) / r.X : S.priceStd;
$("priceHint").textContent = `Blended record price at this mix: ${unit(blended)}. Prices are entered in USD; 1 USD = ${f2(A.thbUsd)} THB, 1 EUR = ${nf(3).format(A.usdEur)} USD.`;
$("ecoK").innerHTML =
kpi(money(e.rev), "revenue a year") +
kpi(money(e.ebitda), `EBITDA, ${e.rev > 0 ? pct(e.ebitda / e.rev) : "0 %"} of revenue`) +
kpi(money(e.op), "operating profit after depreciation") +
kpi(perRec(e.direct), "direct cost per record") +
kpi(isFinite(e.payback) ? f1(e.payback) + " years" : "never", `payback on ${money(e.capex)} capex`);
const eOn = econ(r, b, true), eOff = econ(r, b, false);
const pb = x => isFinite(x.payback) ? f1(x.payback) + " years" : "none";
$("boiNote").innerHTML = e.boiOn
? `<div class="flag ok">BOI promotion: no import duty (~${money(eOff.dutyM)}) and no import VAT (~${money(eOff.vatM)}) on ${money(eOn.imp)} of imported machinery, and ${money(eOff.pvcDuty - eOn.pvcDuty)} a year less duty on PVC compound. Without promotion the capex would be ${money(eOff.capex)} and the payback ${pb(eOff)}.</div>`
: `<div class="flag">Not promoted: ${money(eOff.dutyM)} import duty on ${money(eOff.imp)} of imported machinery goes into the capex and its depreciation; ${money(eOff.vatM)} import VAT is paid at the border and only comes back later as input tax (exports are zero-rated, so mostly as a refund claim); ${money(eOff.pvcDuty)} a year duty on PVC compound. With BOI the payback would be ${pb(eOn)}.</div>`;
const SEG = [["PVC and labels", e.sum(["mat"]), "#b07d48"], ["Packaging", e.sum(["pack"]), "#c9a24a"],
["Lacquers and plating", e.sum(["metal"]), "#8e98a6"], ["Energy", e.sum(["energy"]), "#e2462a"],
["Labour", e.sum(["lab"]), "#2f7de1"], ["Maintenance", e.sum(["maint"]), "#7a6fd6"],
["Overheads", A.overheads, "#6b7a90"], ["Depreciation", e.depr, "#4a5f85"]];
const costAll = SEG.reduce((a, x) => a + x[1], 0), scale = Math.max(e.rev, costAll, 1);
$("ecoBar").innerHTML = SEG.map(([l, v, c]) => `<div style="width:${100 * v / scale}%;background:${c}" title="${l}: ${unit(v / Xr)} a record"></div>`).join("") +
(e.op > 0 ? `<div style="width:${100 * e.op / scale}%;background:var(--accent)" title="Operating profit"></div>` : "");
$("ecoLeg").innerHTML = `<span>Per record, revenue ${perRec(e.rev)}:</span>` +
SEG.map(([l, v, c]) => `<span><i style="background:${c}"></i>${l} ${perRec(v)}</span>`).join("") +
(e.op >= 0 ? `<span><i style="background:var(--accent)"></i>Profit ${perRec(e.op)}</span>`
: `<span style="color:var(--bad)">Loss ${perRec(-e.op)} a record</span>`);
const P = [];
const PG = t => P.push(`<tr class="grp"><td colspan="4">${t}</td></tr>`);
const PR = (l, v, s, tot) => P.push(`<tr${tot ? ' class="tot"' : ""}><td>${l}</td><td class="n${v < 0 ? " neg" : ""}">${money(v)}</td><td class="n${v < 0 ? " neg" : ""}">${perRec(v)}</td><td class="src">${s || ""}</td></tr>`);
PG("Revenue");
PR("Standard records", e.revStd, `${big(r.X - r.sp)} at ${unit(S.priceStd)}`);
PR("Splatter and specials", e.revSpec, `${big(r.sp)} at ${unit(S.priceStd + S.premSpec)}`);
PR("Title services", e.revTitle, `${f0(r.titles)} titles at ${money(S.titleFee)}`);
PR("Revenue", e.rev, "", true);
const GRP = [["Materials, packaging, upkeep", ["mat", "pack", "maint"]], ["Stamper making", ["metal"]], ["Energy", ["energy"]], ["Labour and health care", ["lab"]]];
for (const [t, gs] of GRP) { PG(t); e.L.filter(l => gs.includes(l[0])).forEach(l => PR(l[1], l[2], l[3])); }
PG("Result");
PR("Direct cost", e.direct, "everything above", true);
PR("Contribution", e.contrib, e.rev > 0 ? pct(e.contrib / e.rev) + " of revenue" : "", true);
PR("Overheads", A.overheads, "admin, sales, insurance, facility, IT, fees, marketing");
PR("EBITDA", e.ebitda, e.rev > 0 ? pct(e.ebitda / e.rev) + " of revenue" : "", true);
PR("Depreciation", e.depr, `${money(e.capex)} capex${e.dutyM > 0 ? ` incl. ${money(e.dutyM)} import duty` : ""} over ${f1(A.deprYears)} years`);
PR("Operating profit", e.op, isFinite(e.payback) ? `payback ${f1(e.payback)} years on EBITDA` : "no payback", true);
$("ecoT").innerHTML = `<thead><tr><th>Line</th><th class="n">A year</th><th class="n">A record</th><th>Basis</th></tr></thead><tbody>${P.join("")}</tbody>`;
const rows = [];
const G = t => rows.push(`<tr class="grp"><td colspan="3">${t}</td></tr>`);
const R = (l, v, s) => rows.push(`<tr><td>${l}</td><td class="n">${v}</td><td class="src">${s || ""}</td></tr>`);
G("People");
R("Duplex operators, per shift", r.D * A.opD, "one operator per Duplex cell");
R("Classic attendants, per shift", r.att, `one per ${f0(A.cPerAtt)} Classics (assumption)`);
R("Finishing: inspection, sleeving, packing, per shift", r.fin, `at ${f0(A.finRate)} records an hour per person (estimate)`);
R("Production staff on all shifts", r.staff, `${r.perShift} per shift × ${S.shifts}; add cover for leave`);
R("QC inspectors, all shifts", e.heads.qc, `${f1(A.qcPerShift)} per shift (estimate; scale.md 1 to 2)`);
R("Maintenance technicians", e.heads.maint, `one per ${f0(A.pressPerTech)} presses, at least 2 (estimate)`);
R("Galvanics operators (day work)", r.galv, `${f1(r.partsDay)} parts a day at about ${f0(A.galvRate)} per person (OMS)`);
R("Cutting engineers (day work)", e.heads.cut, "one per lathe (estimate)");
R("Production headcount", e.heads.prod, "add cover for leave");
R("Admin, sales and logistics", e.heads.office, "wages in the overheads (scale.md 3 to 5)");
R("All staff, with health insurance and yearly check-ups", e.heads.all, `risk-factor tests for ${e.heads.exposed} (press floor, galvanics, maintenance)`);
G("Machines");
R("HRP 12 presses (Duplex)", 2 * r.D, "");
R("Classic automatic presses", r.C, "extruder built in; trimming not stated by Newbilt");
R("Extruders", `${r.D} EX 50 + ${r.D} EX 35`, "one main and one colour extruder per Duplex system");
R("Trimmers", r.D, "one per Duplex cell");
R("Heaviest lift", r.C > 0 ? "about 1.85 t" : "1.35 t", r.C > 0 ? "Classic, if it weighs like the Toolex Alpha (unconfirmed)" : "HRP 12 press (Newbilt)");
G("Electrical");
R("Connected load, presses and extruders", f0(r.kvaMach) + " kVA", `${r.P} presses at ${f1(A.pressKVA)} kVA, ${r.E} extruders at ${f1(A.extKVA)} kVA`);
R("Connected load, central hydraulics", f0(r.kvaHyd) + " kVA", `${f1(A.hydKVA)} kVA per press`);
R("Average power while running", f0(r.kw) + " kW", "Newbilt average figures, machines and hydraulics");
R("Energy a year, machines and hydraulics", f0(r.mwh) + " MWh", `${f0(b.hoursYr)} operating hours`);
R("Chiller power at peak cooling", f0(r.chillerKW) + " kW", `COP ${f1(A.cop)} (estimate)`);
R("Air compressors", f1(r.compKW) + " kW", "estimate");
G("Central hydraulics");
R("Pump flow", f0(r.hyd.flow) + " l/min", "up to 200 bar");
R("Installed motor power", f1(r.hyd.motor) + " kW", "");
R("Oil fill, HLP 46", f0(r.hyd.oil) + " l", "");
R("Accumulator volume", f0(r.hyd.acc) + " l", "gas loaded");
G("Steam");
R("Saturated steam, 10 bar, 180 °C", `${f0(r.steam[0])} to ${f0(r.steam[1])} kg/h`, `planned boiler ${f0(A.boiler)} kg/h`);
G("Cooling");
R("Cooling capacity, peak", f0(r.coolPeak) + " kW", `about 98 % for the moulds; planned chiller ${f0(A.chiller)} kW`);
R("Cooling water, typical to peak", `${f0(r.water[0])} to ${f0(r.water[1])} l/min`, "inlet 20 to 25 °C, 10 bar");
G("Compressed air");
R("Compressed air", f0(r.air) + " m³/h", "6 to 10 bar, dew point 3 °C, filtered to 5 µm");
G("Floor and room");
R("Press floor", f0(r.floor) + " m²", `Payap press hall ${f0(A.hall)} m²`);
R("Press room climate", "20 to 26 °C", "35 to 60 % r.H., at least 2.5 m clear height (Newbilt)");
G("Material");
R("PVC compound a year", f0(r.compound) + " t", `${f0(A.weight)} g a record plus ${f0(A.reject)} % rejects`);
R("Through the extruders", f0(r.extKgH) + " kg/h", "record plus trim, per operating hour");
R("Trim back to the granulator", f0(r.trimT) + " t a year", `${f0(A.trim)} g a record, reused as regrind`);
G("Stampers and galvanics");
R("Titles a year", f0(r.titles), `${f0(A.runLen)} records a title`);
R("Electroformed parts a year", f0(r.partsYr), `${r.partsTitle} a title: father, mother and ${r.perSide} stamper${r.perSide > 1 ? "s" : ""} per side`);
R("Per working day", `${f1(r.flow.lacq)} / ${f1(r.flow.fathers)} / ${f1(r.flow.mothers)} / ${f1(r.flow.stampers)}`, "lacquers / fathers / mothers / stampers, before remakes");
const GS = r.gst, su = k => `${GS[k].n} · ${pct(GS[k].u)}`;
R("Lathes", su("lathe"), `units · busy; ${f0(A.lathe)} lacquer sides a day each`);
R("Silvering, Pheenix PASU", su("pasu"), "");
R("Pre-plating, Pheenix PPPU", su("pppu"), "four masters at once");
R("Plating, Pheenix PDPU (2 cells)", su("pdpu"), "a second unit adds redundancy, not capacity, at low load");
R("Passivation, Pheenix PMPB", su("pmpb"), "");
R("Sibert VSF / VSP / VSC", `${GS.vsf.n} / ${GS.vsp.n} / ${GS.vsc.n}`, `busiest ${pct(Math.max(GS.vsf.u, GS.vsp.u, GS.vsc.u))}`);
$("reqT").innerHTML = `<tbody>${rows.join("")}</tbody>`;
$("ladT").innerHTML =
`<thead><tr><th>Fleet</th><th class="n">Duplex</th><th class="n">Classic</th><th class="n">Presses</th><th class="n">Records a year</th><th class="n">Specials a year</th><th class="n">Staff per shift</th><th class="n">Steam, kg/h</th><th class="n">Cooling, kW</th><th class="n">Connected, kVA</th><th class="n">Press floor, m²</th><th class="n">Revenue</th><th class="n">EBITDA</th></tr></thead><tbody>` +
LADDER.map(([n, d, c], i) => {
const x = capacityOf(d, c, b), q = compute(d, c, x, b), qe = econ(q, b);
const cur = r.D === d && r.C === c ? " cur" : "";
return `<tr class="lad${cur}" data-i="${i}"><td>${n}</td><td class="n">${d}</td><td class="n">${c}</td><td class="n">${q.P}</td><td class="n">${big(x)}</td><td class="n">${big(q.sp)}</td><td class="n">${q.perShift}</td><td class="n">${f0(q.steam[1])}</td><td class="n">${f0(q.coolPeak)}</td><td class="n">${f0(q.kva)}</td><td class="n">${f0(q.floor)}</td><td class="n">${money(qe.rev)}</td><td class="n${qe.ebitda < 0 ? " neg" : ""}">${money(qe.ebitda)}</td></tr>`;
}).join("") + "</tbody>";
drawPark(r, b);
for (const [, id] of AS) {
const tr = document.querySelector(`#asT tr[data-id="${id}"]`);
if (tr) tr.classList.toggle("chg", A[id] !== A0[id]);
}
writeHash();
}
// ---------------------------------------------------------------- machine park schematic
function drawPark(r, b) {
const o = [];
const T = (x, y, c, t, a) => o.push(`<text x="${x}" y="${y}" class="${c}"${a ? ` text-anchor="${a}"` : ""}>${t}</text>`);
const box = (x, y, w, h, c, rx) => o.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx == null ? 6 : rx}" class="${c}"/>`);
const arrow = (x1, y1, x2, y2) => o.push(`<path d="M${x1},${y1} L${x2},${y2}" class="pk-arrow" marker-end="url(#pkA)"/>`);
const bar = (x, y, w, u) => { box(x, y, w, 5, "pk-util", 2); box(x, y, Math.max(0, w * Math.min(u, 1)), 5, "pk-utilv" + (u > 0.9 ? " hi" : ""), 2); };
o.push(`<defs><marker id="pkA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="pk-head"/></marker></defs>`);
const glyph = (k, x, y) => {
if (k === "lathe") o.push(`<circle cx="${x + 10}" cy="${y + 11}" r="9" class="pk-glyph"/><circle cx="${x + 10}" cy="${y + 11}" r="2" class="pk-head"/>`);
else if (k === "pdpu") o.push(`<rect x="${x}" y="${y}" width="9" height="22" rx="2" class="pk-glyph"/><rect x="${x + 11}" y="${y}" width="9" height="22" rx="2" class="pk-glyph"/>`);
else if (k === "pppu") o.push(`<rect x="${x}" y="${y}" width="20" height="22" rx="2" class="pk-glyph"/>` + [0, 1, 2, 3].map(i => `<circle cx="${x + 5 + (i % 2) * 10}" cy="${y + 6 + Math.floor(i / 2) * 10}" r="2.4" class="pk-head"/>`).join(""));
else if (k === "pmpb") o.push(`<rect x="${x}" y="${y + 7}" width="20" height="15" rx="2" class="pk-glyph"/>`);
else o.push(`<rect x="${x}" y="${y}" width="20" height="22" rx="2" class="pk-glyph"/><circle cx="${x + 10}" cy="${y + 11}" r="6" class="pk-head" opacity=".55"/>`);
};
const f = r.flow, g = r.gst, days = Math.max(1, S.days);
// 1. stamper production
T(17, 18, "pk-lane", `STAMPER PRODUCTION · ${f1(f.titlesDay)} TITLES A WORKING DAY (${f0(r.titles)} A YEAR) · ${f1(f.parts)} ELECTROFORMED PARTS A DAY`);
const ST = [
["Mastering", "lathe", g.lathe, `${f1(f.lacq)} lacquer sides`, "lathe"],
["Silvering", "Pheenix PASU", g.pasu, `${f1(f.lacq)} lacquers`, "pasu"],
["Pre-plating", "Pheenix PPPU", g.pppu, `${f1(f.lacq)} masters`, "pppu"],
["Plating", "Pheenix PDPU, 2 cells", g.pdpu, `${f1(f.parts)} parts`, "pdpu"],
["Passivation", "Pheenix PMPB", g.pmpb, `${f1(f.parts)} parts`, "pmpb"],
["Back-finish", "Sibert VSF", g.vsf, `${f1(f.stampers)} stampers`, "vsf"],
["Centre punch", "Sibert VSP", g.vsp, `${f1(f.stampers)} stampers`, "vsp"],
["Coin", "Sibert VSC", g.vsc, `${f1(f.stampers)} stampers`, "vsc"],
];
const bw = 130, gp = 18, y0 = 28, bh = 112;
ST.forEach(([t, sub, u, fl, k], i) => {
const x = 17 + i * (bw + gp);
box(x, y0, bw, bh, "pk-box");
T(x + 8, y0 + 17, "pk-t", t); T(x + 8, y0 + 33, "pk-s", sub);
for (let j = 0; j < Math.min(u.n, 3); j++) glyph(k, x + 8 + j * 24, y0 + 38);
T(x + bw - 8, y0 + 58, "pk-n", u.n + "×", "end");
T(x + 8, y0 + 80, "pk-s", fl + " a day");
bar(x + 8, y0 + 88, bw - 16, u.u);
T(x + 8, y0 + 106, "pk-s", pct(u.u) + " busy");
if (i < ST.length - 1) arrow(x + bw + 2, y0 + bh / 2, x + bw + gp - 2, y0 + bh / 2);
});
// 2. press hall, with compound in (left), stampers in and records out (right)
const items = [];
for (let i = 0; i < r.D; i++) items.push(["D", i + 1]);
for (let i = 0; i < r.C; i++) items.push(["C", i + 1]);
const hx = 165, hw = 870, iw = { D: 150, C: 84 }, ih = 104, innerW = hw - 30;
const lay = sc => { let x = 0, y = 0; const pos = [];
for (const it of items) { const w = iw[it[0]] * sc; if (x > 0 && x + w > innerW + 0.5) { x = 0; y += (ih + 12) * sc; } pos.push([x, y]); x += w + 12 * sc; }
return { pos, h: items.length ? y + ih * sc : 0 }; };
let sc = 1, L = lay(1);
while (L.h > 480 && sc > 0.3) { sc *= 0.85; L = lay(sc); }
const y1 = y0 + bh + 44, hh = Math.max(230, 46 + L.h + 18);
box(hx, y1, hw, hh, "pk-hall", 8);
T(hx + 14, y1 + 20, "pk-lane", `PRESS HALL · ${r.D} DUPLEX SYSTEM${r.D === 1 ? "" : "S"} + ${r.C} CLASSIC${r.C === 1 ? "" : "S"} · ${r.P} PRESSES · ~${f0(r.floor)} M² PRESS FLOOR`);
o.push(`<g transform="translate(${hx + hw - 300},${y1 + 10})"><rect width="10" height="10" class="pk-ex50"/><text x="14" y="9" class="pk-s">EX 50</text>` +
`<rect x="62" width="10" height="10" class="pk-ex35"/><text x="76" y="9" class="pk-s">EX 35 colour</text>` +
`<circle cx="160" cy="5" r="5" class="pk-op"/><text x="169" y="9" class="pk-s">operator</text>` +
`<rect x="225" width="10" height="10" class="pk-trim"/><text x="239" y="9" class="pk-s">trimmer</text></g>`);
if (!items.length) T(hx + hw / 2, y1 + hh / 2, "pk-s", "no presses", "middle");
items.forEach(([k, n], i) => {
const [px, py] = L.pos[i], tx = hx + 15 + px, ty = y1 + 34 + py;
if (k === "D") o.push(`<g transform="translate(${tx},${ty}) scale(${sc})">` +
`<rect width="150" height="104" rx="6" class="pk-cell"/>` +
`<path d="M60,24 L42,36 M74,26 L108,36" class="pk-feed"/>` +
`<rect x="-15" y="-15" width="30" height="30" class="pk-press" transform="translate(32,46) rotate(45)"/>` +
`<rect x="-15" y="-15" width="30" height="30" class="pk-press" transform="translate(118,46) rotate(45)"/>` +
`<rect x="57" y="10" width="14" height="30" rx="2" class="pk-ex50"/>` +
`<rect x="77" y="14" width="11" height="24" rx="2" class="pk-ex35"/>` +
`<circle cx="73" cy="57" r="5.5" class="pk-op"/>` +
`<rect x="48" y="66" width="13" height="13" rx="1" class="pk-trim"/>` +
`<text x="75" y="96" class="pk-s" text-anchor="middle">Duplex ${n} · ${pct(r.dUse)}</text></g>`);
else o.push(`<g transform="translate(${tx},${ty}) scale(${sc})">` +
`<rect width="84" height="104" rx="6" class="pk-cell"/>` +
`<rect x="9" y="16" width="11" height="30" rx="2" class="pk-ex50"/>` +
`<rect x="28" y="22" width="36" height="36" rx="3" class="pk-press"/>` +
`<circle cx="33" cy="27" r="3.5" class="pk-col"/><circle cx="59" cy="27" r="3.5" class="pk-col"/><circle cx="46" cy="53" r="3.5" class="pk-col"/>` +
`<circle cx="66" cy="73" r="7" class="pk-disc"/>` +
`<text x="42" y="96" class="pk-s" text-anchor="middle">Classic ${n} · ${pct(r.cUse)}</text></g>`);
});
// compound in
const cy = y1 + 20;
box(17, cy, 130, 112, "pk-box");
T(25, cy + 17, "pk-t", "Compound"); T(25, cy + 33, "pk-s", "colour store and silos");
T(25, cy + 56, "pk-v", `${f0(r.compound)} t a year`);
T(25, cy + 74, "pk-s", `${f0(r.extKgH)} kg/h extruded`);
T(25, cy + 90, "pk-s", `trim back: ${f0(r.trimT)} t a year`);
arrow(149, cy + 56, hx - 2, cy + 56);
// stamper rack, fed from the coin press above, into the hall
const rx0 = 1053, ry = y1 + 6;
arrow(17 + 7 * (bw + gp) + bw / 2, y0 + bh + 2, 17 + 7 * (bw + gp) + bw / 2, ry - 2);
box(rx0, ry, 130, 88, "pk-box");
T(rx0 + 8, ry + 17, "pk-t", "Stamper rack"); T(rx0 + 8, ry + 33, "pk-s", "sets to the presses");
T(rx0 + 8, ry + 54, "pk-v", `${f1(f.stampers)} a day`);
T(rx0 + 8, ry + 72, "pk-s", `${r.perSide} per side a title`);
arrow(rx0 - 2, ry + 44, hx + hw + 2, ry + 44);
// finished records out to finishing
const fy = ry + 108;
box(rx0, fy, 130, 104, "pk-box");
T(rx0 + 8, fy + 17, "pk-t", "Finishing"); T(rx0 + 8, fy + 33, "pk-s", "QC, sleeves, packing");
T(rx0 + 8, fy + 54, "pk-v", `${f0(r.X / days)} a day`);
T(rx0 + 8, fy + 72, "pk-s", `${r.fin} people per shift`);
T(rx0 + 8, fy + 88, "pk-s", `${f0(r.perHour)} records an hour`);
arrow(hx + hw + 2, fy + 52, rx0 - 2, fy + 52);
// 3. utilities, piped up into the hall
const y3 = y1 + hh + 46, uw = 158, ug = (hw - 5 * uw) / 4;
T(hx, y3 - 12, "pk-lane", "CENTRAL UTILITIES");
const U = [
["Central hydraulics", `${f0(r.hyd.flow)} l/min`, `${f1(r.hyd.motor)} kW motors`, `${f0(r.hyd.oil)} l oil, up to 200 bar`, "pk-hyd", false],
["Steam boiler", `${f0(r.steam[1])} kg/h peak`, `from ${f0(r.steam[0])} kg/h, 10 bar`, `planned ${f0(A.boiler)} kg/h`, "pk-steam", r.steam[1] > A.boiler],
["Cooling", `${f0(r.coolPeak)} kW peak`, `${f0(r.water[0])} to ${f0(r.water[1])} l/min`, `planned ${f0(A.chiller)} kW chiller`, "pk-cool", r.coolPeak > A.chiller],
["Compressed air", `${f0(r.air)} m³/h`, "6 to 10 bar, dry", `${f1(r.compKW)} kW compressors`, "pk-air", false],
["Electrical", `${f0(r.kva)} kVA`, `${f0(r.kw)} kW average`, `${f0(r.mwh)} MWh a year`, "pk-pow", false],
];
U.forEach(([t, v, s1, s2, cls, warn], i) => {
const x = hx + i * (uw + ug);
o.push(`<path d="M${x + uw / 2},${y3} L${x + uw / 2},${y1 + hh}" class="pk-pipe ${cls}"/>`);
box(x, y3, uw, 96, "pk-box" + (warn ? " warn" : ""));
T(x + 8, y3 + 17, "pk-t", t);
T(x + 8, y3 + 42, "pk-v", v);
T(x + 8, y3 + 62, "pk-s", s1);
T(x + 8, y3 + 78, "pk-s", s2);
});
const H = y3 + 96 + 12;
const svg = $("park");
svg.setAttribute("viewBox", `0 0 1200 ${H}`);
svg.innerHTML = o.join("");
}
// ---------------------------------------------------------------- inputs and state
const NUM = ["target", "dMin", "cMin", "dFleet", "cFleet", "shifts", "hrs", "days", "eff", "rateD", "rateC", "specF", "priceStd", "premSpec", "titleFee", "exportShare"];
function syncInputs() {
for (const k of NUM) $(k).value = S[k];
$("targetR").value = S.target;
$("spec").value = S.spec;
for (const [, id] of AS) { const el = $("a_" + id); if (el) el.value = A[id]; }
}
function buildAssumptions() {
let last = "", html = `<thead><tr><th>Figure</th><th class="n">Value</th><th>Unit</th><th>Basis</th></tr></thead><tbody>`;
for (const [g, id, label, , unit, kind, src] of AS) {
if (g !== last) { html += `<tr class="grp"><td colspan="4">${esc(g)}</td></tr>`; last = g; }
html += `<tr data-id="${id}"><td>${esc(label)}</td><td class="n"><input type="number" id="a_${id}" step="any" aria-label="${esc(label)}"></td>` +
`<td>${esc(unit)}</td><td class="src"><span class="badge ${kind}">${KIND[kind]}</span> ${esc(src)}</td></tr>`;
}
$("asT").innerHTML = html + "</tbody>";
}
function writeHash() {
const p = [];
for (const k in S0) if (S[k] !== S0[k]) p.push(k + "=" + encodeURIComponent(S[k]));
for (const k in A0) if (A[k] !== A0[k]) p.push("a." + k + "=" + encodeURIComponent(A[k]));
const h = p.length ? "#" + p.join("&") : "";
if (location.hash !== h) history.replaceState(null, "", location.pathname + location.search + h);
}
function readHash() {
for (const part of location.hash.replace(/^#/, "").split("&")) {
if (!part) continue;
const [k, v0] = part.split("="), v = decodeURIComponent(v0 || "");
if (k.startsWith("a.")) { const id = k.slice(2); if (id in A0 && v !== "" && isFinite(+v)) A[id] = +v; }
else if (k in S0) S[k] = typeof S0[k] === "number" ? (v !== "" && isFinite(+v) ? +v : S0[k]) : v;
}
if (!["target", "fleet"].includes(S.mode)) S.mode = "target";
if (!["classic", "duplex"].includes(S.grow)) S.grow = "classic";
if (!["USD", "THB", "EUR"].includes(S.cur)) S.cur = "USD";
if (!["on", "off"].includes(S.boi)) S.boi = "on";
}
function num(el, fallback) { const v = parseFloat(el.value); return isFinite(v) ? v : fallback; }
buildAssumptions();
readHash();
syncInputs();
for (const k of NUM) $(k).addEventListener("input", e => { S[k] = num(e.target, S0[k]); if (k === "target") $("targetR").value = S.target; render(); });
$("targetR").addEventListener("input", e => { S.target = +e.target.value; $("target").value = S.target; render(); });
$("spec").addEventListener("input", e => { S.spec = +e.target.value; render(); });
$("case").addEventListener("click", e => {
const v = e.target.dataset && e.target.dataset.v; if (!v || !CASES[v]) return;
Object.assign(S, CASES[v].S); Object.assign(A, CASES[v].A); syncInputs(); render();
});
for (const g of ["mode", "grow", "cur", "boi"]) $(g).addEventListener("click", e => {
const v = e.target.dataset && e.target.dataset.v; if (!v) return;
if (g === "mode" && v === "fleet" && S.mode === "target") { const { r } = scenario(); S.dFleet = r.D; S.cFleet = r.C; syncInputs(); }
S[g] = v; render();
});
$("asT").addEventListener("input", e => {
const id = e.target.id.replace(/^a_/, ""); if (!(id in A0)) return;
A[id] = num(e.target, A0[id]); render();
});
$("ladT").addEventListener("click", e => {
const tr = e.target.closest("tr.lad"); if (!tr) return;
const [, d, c] = LADDER[+tr.dataset.i];
S.mode = "fleet"; S.dFleet = d; S.cFleet = c; syncInputs(); render();
});
$("reset").addEventListener("click", () => { S = { ...S0 }; A = { ...A0 }; syncInputs(); render(); });
addEventListener("hashchange", () => { S = { ...S0 }; A = { ...A0 }; readHash(); syncInputs(); render(); });
$("copy").addEventListener("click", () => {
const done = () => { $("copy").textContent = "Link copied"; setTimeout(() => { $("copy").textContent = "Copy link to this scenario"; }, 1600); };
if (navigator.clipboard) navigator.clipboard.writeText(location.href).then(done, done); else done();
});
render();
(function(){
var b=document.getElementById("themeBtn"),de=document.documentElement;
function set(t,save){de.setAttribute("data-theme",t);if(save){try{localStorage.setItem("cnx-theme",t)}catch(e){}}
b.textContent=t==="light"?"☾ Dark mode":"☀ Light mode";b.title="Switch to "+(t==="light"?"dark":"light")+" mode";}
set(de.getAttribute("data-theme")||"dark",false);
b.addEventListener("click",function(){set(de.getAttribute("data-theme")==="light"?"dark":"light",true);b.blur();});
var mq=window.matchMedia&&matchMedia("(prefers-color-scheme: light)");
if(mq&&mq.addEventListener)mq.addEventListener("change",function(e){var s=null;try{s=localStorage.getItem("cnx-theme")}catch(_){}if(!s)set(e.matches?"light":"dark",false);});
})();
</script>
</body>
</html>