Press fleet calculator: target output and specials share to fleet mix and needs
Deploy test site / deploy (push) Successful in 37s

User, October 2026: calculations for the later business plan, starting
from 2 Duplex + 1 Classic at 3 records a minute per Duplex and 2 per
Classic, as an interactive tool on the website: pick the yearly output
and the share of splatter/specials, get the machine mix and all the
requirements (people, hydraulics, power, ...).

docs/fleet-calculator.html (single static page, no dependencies):
- target mode sizes the fleet (specials only on Duplex with its EX 35;
  grow with Classics or with Duplex systems; start fleet as the floor);
  fleet mode takes a fleet and gives its output at the specials share;
- output, capacity, utilisation and the mix bar per machine type;
- needs: people per shift and in total, galvanics operators, machines,
  connected kVA and average kW, MWh/yr, central hydraulics (flow, motor,
  oil, accumulator), steam, cooling and cooling water, compressed air,
  press floor, room climate, compound, trim, titles, electroformed parts
  and plating units; checks against the 400 kg/h boiler, 250 kW chiller
  and the 870 m2 Payap press hall;
- ladder from the start fleet to a full Payap press hall, click to load;
- every assumption editable with its source (Newbilt Duplex site
  requirements V14 and offer 240620-04, scale.md, galvanics.md, revenue
  model, Payap model; estimates labelled), shareable link via the hash.
The Classic counts as one HRP 12 press plus one extruder until Newbilt
states figures (Toolex Alpha leaflet as a cross-check).

Site: DEMOS entry (/demos/fleet-calculator/) and landing-page entry.
README bullet and layout line; backlinks from scale.md (capacity) and
pressing-line.md (current plan).
This commit is contained in:
grabowski committed 2026-10-10 18:21:47 +07:00
1 parent 68539d563c
commit c6bec71442
6 files changed
+631 -1

No files matched your search

+596
View File
@@ -0,0 +1,596 @@
<!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>
<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;
}
* { 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: #0a1120; 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: #1b1a12; padding: 7px 10px; margin: 8px 0 0; font-size: 14px; }
.flag.ok { border-color: var(--accent); background: #0f1a14; }
.flag.bad { border-color: var(--bad); background: #1d1214; }
.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: #12261c; }
.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: #9fd0ff; border-color: #2c4c73; }
.badge.doc { color: #b5e8c7; border-color: #2b5a3d; }
.badge.est { color: #ffd59a; border-color: #6b5426; }
.badge.user { color: #e2c4ff; border-color: #563a73; }
#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: 860px; }
</style>
</head>
<body>
<header>
<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>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>What the fleet needs</h2>
<div class="tscroll"><table id="reqT"></table></div>
</section>
</div>
</div>
<div class="wrap">
<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>Not scaled here:</b> 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.</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, 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,
};
// [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"],
["pdpuLo", "Parts per plating unit a day, low", 20, "", "doc", "galvanics.md: 20 to 32 per Pheenix PDPU on two shifts"],
["pdpuHi", "Parts per plating unit a day, high", 32, "", "doc", "galvanics.md"],
]);
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"],
["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" };
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);
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,
pdpu: [partsDay > 0 ? Math.ceil(partsDay / Math.max(1, A.pdpuHi)) : 0, partsDay > 0 ? Math.ceil(partsDay / Math.max(1, A.pdpuLo)) : 0],
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 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 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"]) for (const bt of $(g).children) bt.classList.toggle("on", bt.dataset.v === S[g]);
$("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>`;
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("Galvanics operators (day work)", r.galv, `${f1(r.partsDay)} parts a day at about ${f0(A.galvRate)} per person (OMS)`);
R("Not scaled here", "7 to 11", "mastering, QC, maintenance, admin and logistics (scale.md)");
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("Parts per working day", f1(r.partsDay), "");
R("Plating units (Pheenix PDPU)", r.pdpu[0] === r.pdpu[1] ? r.pdpu[0] : `${r.pdpu[0]} to ${r.pdpu[1]}`, `${f0(A.pdpuLo)} to ${f0(A.pdpuHi)} parts a day each, two shifts; plus one for redundancy`);
$("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></tr></thead><tbody>` +
LADDER.map(([n, d, c], i) => {
const x = capacityOf(d, c, b), q = compute(d, c, x, 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></tr>`;
}).join("") + "</tbody>";
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();
}
// ---------------------------------------------------------------- inputs and state
const NUM = ["target", "dMin", "cMin", "dFleet", "cFleet", "shifts", "hrs", "days", "eff", "rateD", "rateC", "specF"];
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";
}
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(); });
for (const g of ["mode", "grow"]) $(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(); });
$("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();
</script>
</body>
</html>