Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Test

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: python -m pip install -r requirements-dev.txt
- run: python -m pytest -c pytest-verified.ini
- run: npm run test:chart
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This repository is intended to grow into a collection of engineering calculation
- CloudFront routes `/api/*` to an API Gateway HTTP API.
- Separate Python Lambda functions calculate PMV/PPD and moist-air properties.
- API requests are throttled and validated server-side.
- The moist-air page renders an h-x psychrometric chart as browser-native SVG.

The stack deploys independently, so existing calculators can remain available during acceptance testing and migration.

Expand All @@ -32,6 +33,9 @@ python -m pytest -c pytest-verified.ini

The PMV tests include representative values captured from the legacy API. The moist-air tests cover standard conditions, reverse calculation, and invalid-input responses.

The browser chart ports the equations and oblique-coordinate transform from
[`iguchi-lab/psychrometric-chart`](https://github.com/iguchi-lab/psychrometric-chart).

## Deployment

Prerequisites:
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "engineering-calculators",
"private": true,
"type": "module",
"scripts": {
"test:chart": "node --test tests_verified/psychrometric-chart.test.js"
}
}
67 changes: 67 additions & 0 deletions tests_verified/psychrometric-chart.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";

import {
chartTemperature,
humidityRatio,
renderPsychrometricChart,
saturationPressure,
} from "../web-release/air/psychrometric-chart.js";

class MockSvgNode {
constructor(name) {
this.name = name;
this.attributes = {};
this.children = [];
this.textContent = "";
}

setAttribute(key, value) {
this.attributes[key] = String(value);
}

append(child) {
this.children.push(child);
}

replaceChildren(...children) {
this.children = children;
}
}

globalThis.document = {
createElementNS(_namespace, name) {
return new MockSvgNode(name);
},
};

test("Tetens saturation pressure matches the Python source equation", () => {
assert.ok(Math.abs(saturationPressure(25) - 3.1678) < 0.001);
});

test("lower pressure increases humidity ratio", () => {
assert.ok(humidityRatio(25, 50, 80) > humidityRatio(25, 50, 101.325));
});

test("oblique transform is finite at the reference temperature", () => {
assert.equal(chartTemperature(50, 0.02), 50);
});

test("dry-bulb lines lean left below the reference temperature", () => {
assert.ok(chartTemperature(20, 0.02) < chartTemperature(20, 0.005));
});

test("chart renders SVG paths and the calculated state marker", () => {
const svg = new MockSvgNode("svg");
const plotted = renderPsychrometricChart(svg, {
ta: 25,
rh: 50,
x: 9.88,
h: 50.32,
}, 101.325);

assert.equal(plotted, true);
assert.equal(svg.attributes.viewBox, "0 0 960 640");
assert.ok(svg.children.length > 5);
assert.ok(svg.children.some((child) => child.name === "g"));
});
41 changes: 33 additions & 8 deletions web-release/air/app.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,48 @@
import { debounce, fetchJson } from "/assets/common.js";
const form=document.querySelector("#air-form"),mode=document.querySelector("#mode"),button=document.querySelector("#calculate"),status=document.querySelector("#status"),metrics=document.querySelector("#metrics");
import { downloadChartSvg, renderPsychrometricChart } from "./psychrometric-chart.js";

const form=document.querySelector("#air-form"),mode=document.querySelector("#mode"),button=document.querySelector("#calculate"),status=document.querySelector("#status"),metrics=document.querySelector("#metrics"),chart=document.querySelector("#psychrometric-chart"),chartStatus=document.querySelector("#chart-status"),downloadButton=document.querySelector("#download-chart");
let activeRequest;

function updateFields(){
const enabledNames=new Set(mode.value.split("-"));
for(const name of ["ta","rh","td","x"]){ const wrapper=form.querySelector(`[data-field="${name}"]`),input=form.elements[name],enabled=enabledNames.has(name); wrapper.classList.toggle("hidden",!enabled); input.disabled=!enabled; }
for(const name of ["ta","rh","td","x"]){
const wrapper=form.querySelector(`[data-field="${name}"]`),input=form.elements[name],enabled=enabledNames.has(name);
wrapper.classList.toggle("hidden",!enabled);
input.disabled=!enabled;
}
}

async function calculate(){
if(!form.reportValidity())return;
activeRequest?.abort(); activeRequest=new AbortController(); button.disabled=true; status.className="status"; status.textContent="計算しています…";
activeRequest?.abort();
activeRequest=new AbortController();
button.disabled=true;
status.className="status";
status.textContent="計算しています…";
try {
const query=new URLSearchParams(new FormData(form)),data=await fetchJson(`/api/air?${query}`,activeRequest.signal);
for(const key of ["ta","rh","td","x","h","rho"])document.querySelector(`[data-result="${key}"]`).textContent=data[key];
metrics.hidden=false; status.textContent="計算しました。";
} catch(error) { if(error.name!=="AbortError"){ status.className="status error"; status.textContent=error.message; } }
finally { button.disabled=false; }
metrics.hidden=false;
status.textContent="計算しました。";
const pressureKpa=Number(form.elements.pressure.value)/1000;
const plotted=renderPsychrometricChart(chart,data,pressureKpa);
chartStatus.textContent=plotted ? "計算結果を赤い点で表示しています。" : "計算結果は線図の表示範囲外です。";
downloadButton.disabled=false;
} catch(error) {
if(error.name!=="AbortError"){
status.className="status error";
status.textContent=error.message;
}
} finally {
button.disabled=false;
}
}

const calculateSoon=debounce(calculate,300);
mode.addEventListener("change",()=>{updateFields();calculate();});
form.addEventListener("input",event=>{if(event.target!==mode)calculateSoon();});
form.addEventListener("submit",event=>{event.preventDefault();calculate();});
updateFields(); calculate();

downloadButton.addEventListener("click",()=>downloadChartSvg(chart));
updateFields();
calculate();
10 changes: 9 additions & 1 deletion web-release/air/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@ <h1>湿り空気計算</h1><p class="lead">既知の2項目を選び、空気の
</form>
<section class="card result" aria-live="polite"><h2>計算結果</h2><p id="status" class="status">計算しています…</p><dl id="metrics" class="metrics" hidden><div><dt>乾球温度</dt><dd><span data-result="ta">—</span> ℃</dd></div><div><dt>相対湿度</dt><dd><span data-result="rh">—</span> %</dd></div><div><dt>露点温度</dt><dd><span data-result="td">—</span> ℃</dd></div><div><dt>絶対湿度</dt><dd><span data-result="x">—</span> g/kg(DA)</dd></div><div><dt>比エンタルピー</dt><dd><span data-result="h">—</span> kJ/kg(DA)</dd></div><div><dt>湿り空気密度</dt><dd><span data-result="rho">—</span> kg/m³</dd></div></dl></section>
</div>
<section class="card chart-card">
<div class="chart-heading">
<div><h2>湿り空気線図</h2><p class="lead">入力した気圧でh-x線図を描き、計算結果を状態点として表示します。</p></div>
<button id="download-chart" type="button" disabled>SVGを保存</button>
</div>
<div class="chart-scroll"><svg id="psychrometric-chart" class="psychrometric-chart" role="img" aria-labelledby="chart-title chart-description"></svg></div>
<p id="chart-status" class="status" aria-live="polite">計算後に状態点を表示します。</p>
<p class="chart-source">描画ロジック: <a href="https://github.com/iguchi-lab/psychrometric-chart" target="_blank" rel="noopener noreferrer">iguchi-lab/psychrometric-chart</a> の計算式をブラウザ用SVGへ移植。</p>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>

Loading
Loading