diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..266c41b --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/README.md b/README.md index 4161c70..0867170 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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: diff --git a/package.json b/package.json new file mode 100644 index 0000000..5d393e1 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "engineering-calculators", + "private": true, + "type": "module", + "scripts": { + "test:chart": "node --test tests_verified/psychrometric-chart.test.js" + } +} diff --git a/tests_verified/psychrometric-chart.test.js b/tests_verified/psychrometric-chart.test.js new file mode 100644 index 0000000..678e668 --- /dev/null +++ b/tests_verified/psychrometric-chart.test.js @@ -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")); +}); diff --git a/web-release/air/app.js b/web-release/air/app.js index 8339044..6d76be4 100644 --- a/web-release/air/app.js +++ b/web-release/air/app.js @@ -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(); diff --git a/web-release/air/index.html b/web-release/air/index.html index f79be8b..94b9345 100644 --- a/web-release/air/index.html +++ b/web-release/air/index.html @@ -17,8 +17,16 @@
既知の2項目を選び、空気の
計算しています… 入力した気圧でh-x線図を描き、計算結果を状態点として表示します。 計算後に状態点を表示します。 描画ロジック: iguchi-lab/psychrometric-chart の計算式をブラウザ用SVGへ移植。計算結果
湿り空気線図