psychart
Version:
View air conditions on a psychrometric chart
349 lines (348 loc) • 15.7 kB
JavaScript
import * as SMath from 'smath';
import { Chart } from '../chart.js';
import { defaultOptions, defaultDataOptions } from './defaults.js';
import { Color, Palette } from 'viridis';
import { zero } from './lib.js';
import { FlowUnits, HeadUnits, PowerUnits, SpeedUnits } from './units.js';
import { dimensions, Quantity, units } from 'dimensional';
/**
* Show a pump's relationship between flow rate and pressure at different operating conditions.
*/
export class Pumpchart extends Chart {
/**
* Layers of the SVG as groups.
*/
g = {
hilites: document.createElementNS(this.NS, 'g'),
curves: document.createElementNS(this.NS, 'g'),
axes: document.createElementNS(this.NS, 'g'),
data: document.createElementNS(this.NS, 'g'),
text: document.createElementNS(this.NS, 'g'),
tips: document.createElementNS(this.NS, 'g'),
};
/**
* The maximum flow rate shown on the x-axis
*/
maxFlow;
/**
* The maximum head pressure shown on the y-axis
*/
maxHead;
/**
* Get the ideal axis interval
*/
static getStep(range, maxIntervals) {
const steps = [1, 2, 2.5, 5];
let magnitude = 1;
while (magnitude < 10) {
for (const stepi of steps) {
const step = stepi * (10 ** magnitude);
if (range / step < maxIntervals) {
return step;
}
}
magnitude++;
}
return 1;
}
/**
* Get the list of all available units for flow.
* @returns A list of units
*/
static getFlowUnits() {
return Object.keys(FlowUnits);
}
/**
* Get the list of all available units for head.
* @returns A list of units
*/
static getHeadUnits() {
return Object.keys(HeadUnits);
}
/**
* Get the list of all available units for speed.
* @returns A list of units
*/
static getSpeedUnits() {
return Object.keys(SpeedUnits);
}
/**
* Get the list of all available units for power.
* @returns A list of units
*/
static getPowerUnits() {
return Object.keys(PowerUnits);
}
/**
* Create a new Pumpchart with custom options.
* @param options Customization options for the new Pumpchart.
*/
constructor(options = {}) {
super(options, defaultOptions);
// Append all groups to the SVG and clear highlights on click.
Object.values(this.g).forEach(group => this.svg.appendChild(group));
this.svg.addEventListener('click', () => Chart.clearChildren(this.g.hilites));
// Compute the axes limits and intervals
this.maxFlow = 1.1 * SMath.clamp(this.options.curve.pump.maxFlow, 0, Infinity);
this.maxHead = 1.1 * SMath.clamp(this.options.curve.pump.maxHead, 0, Infinity);
const flowStep = Pumpchart.getStep(this.maxFlow, this.options.size.x / this.options.font.size / 6);
const headStep = Pumpchart.getStep(this.maxHead, this.options.size.y / this.options.font.size / 3);
// Create the axes.
const xFlowAxis = this.createPath([
{ flow: 0, head: 0 },
{ flow: this.maxFlow, head: 0 },
]);
const yHeadAxis = this.createPath([
{ flow: 0, head: 0 },
{ flow: 0, head: this.maxHead },
]);
xFlowAxis.setAttribute('stroke', this.options.axisColor);
yHeadAxis.setAttribute('stroke', this.options.axisColor);
xFlowAxis.setAttribute('stroke-width', `${this.options.axisWidth * 2}px`);
yHeadAxis.setAttribute('stroke-width', `${this.options.axisWidth * 2}px`);
xFlowAxis.setAttribute('stroke-linecap', 'round');
yHeadAxis.setAttribute('stroke-linecap', 'round');
this.g.axes.append(xFlowAxis, yHeadAxis);
for (let flow = flowStep; flow < this.maxFlow; flow += flowStep) {
// Draw iso-flow vertical lines
const isoFlowLine = this.createPath([
{ flow: flow, head: 0 },
{ flow: flow, head: this.maxHead },
], false);
isoFlowLine.setAttribute('stroke', this.options.axisColor);
isoFlowLine.setAttribute('stroke-width', this.options.axisWidth + 'px');
isoFlowLine.setAttribute('stroke-linecap', 'round');
this.g.axes.appendChild(isoFlowLine);
// Show axis label
this.drawLabel(`${flow}${this.options.units.flow}`, { flow: flow, head: 0 }, 2 /* TextAnchor.N */, `Flow [${this.options.units.flow}]`);
}
for (let head = headStep; head < this.maxHead; head += headStep) {
// Draw iso-head horizontal lines
const isoHeadLine = this.createPath([
{ flow: 0, head: head },
{ flow: this.maxFlow, head: head },
], false);
isoHeadLine.setAttribute('stroke', this.options.axisColor);
isoHeadLine.setAttribute('stroke-width', this.options.axisWidth + 'px');
isoHeadLine.setAttribute('stroke-linecap', 'round');
this.g.axes.appendChild(isoHeadLine);
// Show axis label
this.drawLabel(`${head}${this.options.units.head}`, { flow: 0, head: head }, 4 /* TextAnchor.E */, `Head [${this.options.units.head}]`);
}
// Draw the system curve
const sysColor = Color.hex(this.options.systemCurveColor);
const nmax = SMath.clamp(this.options.speed.max, 0, Infinity); // max speed
const nop = SMath.clamp(this.options.speed.operation, 0, nmax); // operation speed
const qmax = zero(q => this.s(q) - this.p(q, nmax), 0, 1e6); // flow @ max speed
const qop = zero(q => this.s(q) - this.p(q, nop), 0, 1e6); // flow @ operation
const opPt = { flow: qop, head: this.p(qop, nop), speed: nop }; // operation point
this.drawCurve('System Curve', sysColor, 2 * this.options.axisWidth, q => this.s(q), 0, qmax);
// Draw operation axis lines
const operation = this.createPath([
{ flow: 0, head: opPt.head },
opPt,
{ flow: opPt.flow, head: 0 },
], false);
operation.setAttribute('fill', 'none');
operation.setAttribute('stroke', sysColor.toString());
operation.setAttribute('stroke-width', `${this.options.axisWidth}px`);
operation.setAttribute('stroke-linecap', 'round');
this.g.curves.append(operation);
// Draw the operating point
this.plot(opPt, {
name: 'Operation Point',
radius: 5 * this.options.axisWidth,
color: this.options.systemCurveColor,
});
// Draw concentric pump performance curves
const pumpColor = Color.hex(this.options.pumpCurveColor);
this.drawCurve(`Performance Curve at ${SMath.round2(nmax, 0.1)}${this.options.units.speed}`, pumpColor, this.options.axisWidth * 2, q => this.p(q, nmax));
this.options.speed.steps.forEach(speed => {
this.drawCurve('', pumpColor, this.options.axisWidth, q => this.p(q, speed), 0);
});
// Copy over the operation point to the curves layer
if (this.g.data.lastChild) {
this.g.curves.appendChild(this.g.data.lastChild);
this.clearData();
}
}
/**
* Convert a state to an (x,y) coordinate.
* @param state Any state in this system
* @returns An (x,y) coordinate on the screen
*/
state2xy(state) {
const xMin = this.options.padding.x;
const xMax = this.options.size.x - this.options.padding.x;
const yMin = this.options.padding.y;
const yMax = this.options.size.y - this.options.padding.y;
return {
x: SMath.clamp(SMath.translate(state.flow, 0, this.maxFlow, xMin, xMax), xMin, xMax),
y: SMath.clamp(SMath.translate(state.head, 0, this.maxHead, yMax, yMin), yMin, yMax),
};
}
/**
* Create a SVG path element from an array of states.
* @param data An array of states
* @param closePath Whether or not to close the path
* @returns A `<path>` element containing the array of states
*/
createPath(data, closePath = false) {
const path = document.createElementNS(this.NS, 'path');
path.setAttribute('d', 'M ' + data.map(pt => {
const xy = this.state2xy(pt);
return xy.x + ',' + xy.y;
}).join(' ') + (closePath ? ' z' : ''));
return path;
}
/**
* Draw an axis label
* @param content Label text content
* @param location Label location (state)
* @param color Label font color
* @param anchor Label text anchor
* @param tooltip Optional tooltip text on mouse hover
*/
drawLabel(content, location, anchor, tooltip) {
const textColor = Color.hex(this.options.textColor);
const label = this.createLabel(content, this.state2xy(location), textColor, anchor, 0);
this.g.text.appendChild(label);
if (tooltip) {
label.addEventListener('mouseover', e => this.drawTooltip(tooltip, { x: e.offsetX, y: e.offsetY }, textColor, this.g.tips));
label.addEventListener('mouseleave', () => Chart.clearChildren(this.g.tips));
}
}
/**
* Draw a curve `h = f(q)` on the curves layer.
*/
drawCurve(tooltip, color, width, h, min = 0, max = this.maxFlow, steps = 1e3) {
const states = SMath.linspace(min, max, steps).map(q => { return { flow: q, head: h(q) }; });
const curve = this.createPath(states, false);
curve.setAttribute('fill', 'none');
curve.setAttribute('stroke', color.toString());
curve.setAttribute('stroke-width', `${width}px`);
curve.setAttribute('stroke-linecap', 'round');
this.g.curves.appendChild(curve);
if (tooltip) {
curve.addEventListener('mouseover', e => this.drawTooltip(tooltip, { x: e.offsetX, y: e.offsetY }, color, this.g.tips));
curve.addEventListener('mouseleave', () => Chart.clearChildren(this.g.tips));
}
}
/**
* Draw a custom circle onto any layer.
*/
drawCircle(tooltip, color, state, radius, allowHighlight, parent) {
const circle = document.createElementNS(this.NS, 'circle');
const center = this.state2xy(state);
circle.setAttribute('fill', color.toString());
circle.setAttribute('cx', `${center.x}px`);
circle.setAttribute('cy', `${center.y}px`);
circle.setAttribute('r', `${radius}px`);
parent.appendChild(circle);
if (tooltip) {
circle.addEventListener('mouseover', e => this.drawTooltip(tooltip, { x: e.offsetX, y: e.offsetY }, color, this.g.tips));
circle.addEventListener('mouseleave', () => Chart.clearChildren(this.g.tips));
}
if (allowHighlight) {
circle.addEventListener('click', e => {
e.stopPropagation();
this.drawCircle('', Color.hex(this.options.highlightColor), state, radius * 2, false, this.g.hilites);
});
}
}
/**
* Represents the pump curve `h = p(q)`
* @param q Flow rate
* @param speed Pump speed
* @returns Head gained by the fluid by the pump
*/
p(q, speed) {
const n = SMath.clamp(SMath.normalize(speed, 0, this.options.speed.max), 0.01, 1);
const h0 = SMath.clamp(this.options.curve.pump.maxHead * n, 0, Infinity);
const q0 = SMath.clamp(this.options.curve.pump.maxFlow * n, 0, Infinity);
return h0 * (1 - (q / q0) ** 2);
}
/**
* Represents the system curve `h = s(q)`
* @param q Flow rate
* @returns Head loss from the fluid by the system
*/
s(q) {
const h0 = SMath.clamp(this.options.curve.system.static, 0, Infinity);
const k = SMath.clamp(this.options.curve.system.friction, 0, Infinity);
return h0 + k * q ** 2;
}
/**
* Plot a single data point.
* @param state The current state of the system
* @param config Display options for plotting data
*/
plot(state, config = {}) {
const options = Chart.setDefaults(config, defaultDataOptions);
const hasTimeStamp = Number.isFinite(options.timestamp);
const nmax = SMath.clamp(this.options.speed.max, 0, Infinity);
const speedEstimator = n => this.p(state.flow, n) - state.head;
let speed;
try {
speed = state.speed ?? zero(speedEstimator, 0, nmax);
}
catch {
speed = nmax;
}
// Calculate the efficiency if power is given
let efficiency = 0;
let output = 0;
if (typeof state.power === 'number') {
let headQty = new Quantity(state.head, HeadUnits[this.options.units.head]);
if (HeadUnits[this.options.units.head].dimensions.is(dimensions.Length)) {
// Need to multiply by specific weight to get the head in units of pressure
const specWeight = new Quantity(SMath.clamp(this.options.specificGravity, 0, Infinity), units.gram.over(units.centimeter.pow(3)).times(units.Gs));
headQty = headQty.times(specWeight);
}
// Efficiency = Power_{out} / Power_{in}
// Power_{out} = Pressure * FlowRate
const flowQty = new Quantity(state.flow, FlowUnits[this.options.units.flow]);
const powQty = new Quantity(state.power, PowerUnits[this.options.units.power]);
const eta = headQty.times(flowQty).over(powQty);
output = headQty.times(flowQty).as(powQty.units).quantity;
efficiency = eta.as(units.Unitless).quantity * 100;
}
const tip = (options.name ? `${options.name}\n` : '') +
(hasTimeStamp ? `${new Date(options.timestamp).toLocaleString()}\n` : '') +
`Flow = ${SMath.round2(state.flow, 0.1)}${this.options.units.flow}` +
`\nHead = ${SMath.round2(state.head, 0.1)}${this.options.units.head}` +
`\nSpeed = ${SMath.round2(speed, 0.1)}${this.options.units.speed}${typeof state.speed === 'number' ? '' : ' (est.)'}` +
(typeof state.power === 'number' ? (`\nPower = ${SMath.round2(state.power, 0.1)}${this.options.units.power}` +
`\nOutput = ${SMath.round2(output, 0.1)}${this.options.units.power}` +
`\nEfficiency = ${SMath.round2(efficiency, 0.1)}%`) : '');
// Determine the assigned color for this data point
let gradientMin = 0;
let gradientMax = 0;
let gradientValue = 0;
let useGradient = false;
if (this.options.colorizeBy === 'time' && hasTimeStamp) {
gradientMin = this.options.timestamp.start;
gradientMax = this.options.timestamp.stop;
gradientValue = options.timestamp;
useGradient = true;
}
else if (this.options.colorizeBy === 'efficiency' && typeof state.power === 'number') {
gradientMin = 0;
gradientMax = 100;
gradientValue = efficiency;
useGradient = true;
}
if (useGradient && this.options.flipGradient) {
[gradientMin, gradientMax] = [gradientMax, gradientMin];
}
const color = useGradient ? Palette[this.options.gradient].getColor(gradientValue, gradientMin, gradientMax) : Color.hex(options.color);
this.drawCircle(tip, color, state, options.radius, true, this.g.data);
}
/**
* Clear all the data from this chart.
*/
clearData() {
Chart.clearChildren(this.g.data);
Chart.clearChildren(this.g.hilites);
}
}