Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
3
apps/web/.eslintrc.json
Normal file
3
apps/web/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
75
apps/web/README.md
Normal file
75
apps/web/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Entropyk Web UI
|
||||
|
||||
Visual canvas builder for Entropyk thermodynamic cycles.
|
||||
|
||||
The UI emits the same `ScenarioConfig` JSON as the CLI
|
||||
(`crates/cli/src/config.rs`). Physics always runs through
|
||||
`entropyk_cli::run::simulate_from_json` via the Axum `ui-server`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- The Rust API server running (see below)
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Start the Rust API server
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
cargo run -p entropyk-demo --bin ui-server
|
||||
# → http://localhost:3030
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
- `GET /api/health`
|
||||
- `GET /api/components`
|
||||
- `POST /api/simulate`
|
||||
- `GET /api/mollier?fluid=R134a`
|
||||
|
||||
### 2. Start the Next.js dev server
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
npm install
|
||||
npm run dev
|
||||
# → http://localhost:3000
|
||||
```
|
||||
|
||||
By default the frontend proxies `/api/entropyk/*` to `http://localhost:3030/api/*`
|
||||
(see `next.config.mjs`). To point at a different API, set:
|
||||
|
||||
```bash
|
||||
ENTROPYK_API_URL=http://my-host:3030 npm run dev
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Drag components from the left palette onto the canvas.
|
||||
2. Connect ports by dragging from one handle to another.
|
||||
3. Select a node to edit its parameters in the right panel.
|
||||
4. Pick the fluid and backend in the top bar.
|
||||
5. Click **Solve cycle** — the result panel shows COP, edge states and a P-h diagram.
|
||||
6. **Load** drops in a CLI example from the picker; **Import JSON** loads any CLI config.
|
||||
7. **Multi-run** sweeps parameters (fluid, UA, …) and solves cases in parallel.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (Next.js) Rust API (Axum ui-server :3030)
|
||||
React Flow canvas → POST /api/simulate
|
||||
Zustand store → simulate_from_json() (CLI engine)
|
||||
Recharts P-h diagram ← SimulationResult JSON
|
||||
```
|
||||
|
||||
Anything buildable in the UI can also be run with:
|
||||
|
||||
```bash
|
||||
cargo run -p entropyk-cli -- run path/to/config.json
|
||||
```
|
||||
|
||||
## Deprecated surfaces
|
||||
|
||||
- `ui/` at the repo root — legacy static HTML; calls removed `/api/calculate`. Do not use.
|
||||
- `tools/circuit-builder-ui` — alternate HTML builder; prefer `apps/web`.
|
||||
5
apps/web/next-env.d.ts
vendored
Normal file
5
apps/web/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
12
apps/web/next.config.mjs
Normal file
12
apps/web/next.config.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
async rewrites() {
|
||||
const api = process.env.ENTROPYK_API_URL || "http://localhost:3030";
|
||||
return [
|
||||
{ source: "/api/entropyk/:path*", destination: `${api}/api/:path*` },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
9537
apps/web/package-lock.json
generated
Normal file
9537
apps/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
apps/web/package.json
Normal file
45
apps/web/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "entropyk-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.3.5",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "15.1.3",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.21.5": true,
|
||||
"unrs-resolver@1.12.2": true
|
||||
}
|
||||
}
|
||||
9
apps/web/postcss.config.mjs
Normal file
9
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
137
apps/web/public/docs/components/README.md
Normal file
137
apps/web/public/docs/components/README.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Entropyk Component Documentation / Documentation des composants Entropyk
|
||||
|
||||
> Bilingual reference (EN + FR) for every component usable from the CLI JSON config and the web UI.
|
||||
> Référence bilingue (EN + FR) pour chaque composant CLI / UI.
|
||||
>
|
||||
> Each page documents: physical model, correlations (if any), residual equations, `n_equations()`,
|
||||
> ports, system vs rating secondary, calibration Z-factors, and JSON parameters with defaults.
|
||||
>
|
||||
> Chaque fiche documente : modèle, corrélations, résidus, ports, modes système/rating, facteurs Z, paramètres JSON.
|
||||
|
||||
**Last major doc refresh:** 2026-07-17 — dual-mode HX Newton, compressor maps (AHRI / SST–SDT), correlation inventory, UI Fixed/Free, fallback solver.
|
||||
|
||||
### Full correlation & map inventory
|
||||
|
||||
→ **[correlations-and-maps.md](./correlations-and-maps.md)** — AHRI 540, screw bilinear presets, Longo/Shah/…, ε-NTU, pumps/fans, ΔP.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
### State / État
|
||||
|
||||
- **State per edge:** `(ṁ, P, h)`. Series branches share one ṁ unknown (`same_branch_m`, CM1.4).
|
||||
- **État par arête :** `(ṁ, P, h)`. Branches en série → un seul ṁ.
|
||||
|
||||
### DoF (degrees of freedom)
|
||||
|
||||
A real-machine solve requires **`n_equations = n_unknowns`**.
|
||||
|
||||
- **FIX** = impose a quantity (boundary T/P/ṁ, outlet SH/SC, quality residual, measured calib target…) → +equation or pin.
|
||||
- **FREE** = solver unknown (emergent pressure, free opening, free `z_ua`, …).
|
||||
|
||||
CLI hard-fails on imbalance (`validate_system_dof`). Web UI: Fixed checkboxes + balance bar.
|
||||
|
||||
### System vs rating secondary (HX)
|
||||
|
||||
| Mode | Secondary definition | Secondary unknowns | Newton duty |
|
||||
|------|----------------------|--------------------|-------------|
|
||||
| **System** | Live ports `secondary_inlet` / `secondary_outlet` + Source/Sink | yes | ε-NTU Q from edge state |
|
||||
| **Rating** | Scalars `secondary_inlet_temp_*` + ṁ·cp or `C_sec` | no | ε-NTU Q from scalars **in residuals** |
|
||||
|
||||
Both modes are first-class for Condenser / Evaporator / FloodedEvaporator.
|
||||
Scalars are **not** limited to offline `rate()` only.
|
||||
|
||||
### Zero flow
|
||||
|
||||
Valid state. HX use `flow_regularization` (smooth `|ṁ|`, activity, Δh hold). See [flow-regularization.md](./flow-regularization.md).
|
||||
|
||||
### Emergent pressure
|
||||
|
||||
`emergent_pressure: true` lets condensing/evaporating pressure float from HX ↔ secondary energy balance instead of a fixed design pin.
|
||||
|
||||
### Calibration Z-factors (BOLT)
|
||||
|
||||
Default **1.0** = no correction. Typical range ~0.2–3 for inverse calib; production often ~0.8–1.2.
|
||||
|
||||
| Entropyk | BOLT | Effect |
|
||||
|----------|------|--------|
|
||||
| `z_flow` | `Z_flow_suc`, … | ṁ_eff = z_flow × ṁ_nom |
|
||||
| `z_dp` | `Z_dpc`, … | ΔP_eff = z_dp × ΔP_nom |
|
||||
| `z_ua` | `Z_UA`, `Z_Uev`, `Z_Ucd` | UA_eff = z_ua × UA_nom |
|
||||
| `z_power` | `Z_power` | Ẇ_eff = z_power × Ẇ_nom |
|
||||
| `z_etav` | — | η_v scale |
|
||||
|
||||
Legacy `f_*` and BOLT `Z_*` spellings accepted in JSON.
|
||||
Recommended order: `z_flow → z_dp → z_ua → z_power → z_etav`.
|
||||
|
||||
**UI calibration pattern:** Fixed on measure (SST/SDT) + Free on `z_ua` (do **not** require the Advanced “Regulation loop” node for simple Z_UA calib).
|
||||
|
||||
### Solver strategies (CLI)
|
||||
|
||||
| `solver.strategy` | Behaviour |
|
||||
|-------------------|-----------|
|
||||
| `newton` | Newton–Raphson |
|
||||
| `picard` | Sequential substitution |
|
||||
| `fallback` | Intelligent Newton → Picard (`FallbackSolver`) |
|
||||
|
||||
---
|
||||
|
||||
## Compressors / Compresseurs
|
||||
|
||||
- [IsentropicCompressor](./isentropic-compressor.md)
|
||||
- [ScrewEconomizerCompressor / ScrewCompressor](./screw-economizer-compressor.md)
|
||||
- [Compressor (AHRI 540)](./compressor-ahri540.md)
|
||||
|
||||
## Heat exchangers / Échangeurs
|
||||
|
||||
| Component | Model / correlations | Notes |
|
||||
|-----------|----------------------|--------|
|
||||
| [Condenser](./condenser.md) | ε-NTU phase-change | dual secondary modes |
|
||||
| [Evaporator](./evaporator.md) | ε-NTU DX + SH | dual secondary modes |
|
||||
| [FloodedEvaporator](./flooded-evaporator.md) | ε-NTU + sat-vapor / quality | dual secondary modes |
|
||||
| [FloodedCondenser](./flooded-condenser.md) | inner ε-NTU + SC control | prefer Condenser in production |
|
||||
| [BphxEvaporator / BphxCondenser](./bphx.md) | **Longo / Shah** → UA + ε-NTU | geometry + correlation |
|
||||
| [AirCooledCondenser](./air-cooled-condenser.md) | air-side coil | |
|
||||
| [FinCoilCondenser](./fin-coil-condenser.md) | finned coil | |
|
||||
| [MchxCondenserCoil](./mchx-condenser-coil.md) | microchannel | |
|
||||
| [HeatExchanger (generic)](./heat-exchanger-generic.md) | generic ε-NTU / LMTD | |
|
||||
| [FreeCoolingExchanger](./free-cooling-exchanger.md) | free cooling | |
|
||||
| [Economizer](./economizer.md) | internal LMTD HX | not always a CLI leaf |
|
||||
| [MovingBoundaryHX](./moving-boundary-hx.md) | multi-zone UA ID | research path |
|
||||
| [Flow regularization](./flow-regularization.md) | zero-flow helpers | shared |
|
||||
|
||||
## Valves & expansion
|
||||
|
||||
- [IsenthalpicExpansionValve / EXV](./isenthalpic-expansion-valve.md)
|
||||
- [ExpansionValve](./expansion-valve.md)
|
||||
- [ReversingValve](./reversing-valve.md)
|
||||
- [BypassValve](./bypass-valve.md)
|
||||
|
||||
## Flow network
|
||||
|
||||
- [FlowSplitter](./flow-splitter.md)
|
||||
- [FlowMerger](./flow-merger.md)
|
||||
- [Pipe](./pipe.md)
|
||||
- [Drum](./drum.md)
|
||||
|
||||
## Rotating machines
|
||||
|
||||
- [Fan](./fan.md)
|
||||
- [Pump](./pump.md)
|
||||
|
||||
## Boundaries
|
||||
|
||||
- [Refrigerant / Brine / Air Sources & Sinks](./boundaries.md)
|
||||
|
||||
## Inline nodes
|
||||
|
||||
- [Anchor & HeatSource](./anchor-heat-source.md)
|
||||
|
||||
## Inter-circuit coupling
|
||||
|
||||
- [ThermalLoad](./thermal-load.md)
|
||||
|
||||
---
|
||||
|
||||
See also: system capability notes under `docs/` and example machines in `crates/cli/examples/`.
|
||||
50
apps/web/public/docs/components/air-cooled-condenser.md
Normal file
50
apps/web/public/docs/components/air-cooled-condenser.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# AirCooledCondenser
|
||||
|
||||
Config type: `"AirCooledCondenser"`
|
||||
Source: air-cooled condenser / coil stack in components
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Air-cooled condenser: refrigerant condensation against outdoor air stream. Combines refrigerant-side phase-change energy balance with air-side capacity (fan flow × cp_air × effectiveness or coil model).
|
||||
|
||||
```
|
||||
Q = ε · C_air · (T_cond − T_air,in) # schematic ε-NTU air-side form
|
||||
```
|
||||
|
||||
May wrap or specialize `Condenser` with air secondary defaults.
|
||||
|
||||
### Residuals
|
||||
|
||||
Similar to Condenser coupled path: refrigerant energy/momentum + air secondary when live ports or rating air stream set.
|
||||
|
||||
### Ports
|
||||
|
||||
Refrigerant inlet/outlet + air secondary_in/out when 4-port.
|
||||
|
||||
### Calibration
|
||||
|
||||
`z_ua` default **1.0**; fan speed may be free under head-pressure control.
|
||||
|
||||
### JSON
|
||||
|
||||
UA / coil geometry / OAT / face velocity / design capacity depending on arm — see CLI `create_component` and example air-cooled chillers.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
**Condenseur à air** : rejet de chaleur vers l’air extérieur.
|
||||
|
||||
### Calibration
|
||||
|
||||
Z_UA = 1 par défaut ; vitesse ventilateur possible en régulation.
|
||||
|
||||
### JSON
|
||||
|
||||
Voir exemples CLI air-cooled.
|
||||
60
apps/web/public/docs/components/anchor-heat-source.md
Normal file
60
apps/web/public/docs/components/anchor-heat-source.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Anchor & HeatSource (inline BOLT-style nodes)
|
||||
|
||||
Config types: `"Anchor"` / `"RefrigerantNode"`, `"HeatSource"`
|
||||
Source: anchor / heat source modules in components
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Anchor (Refrigerant.Node)
|
||||
|
||||
Inline **probe or spec** on a refrigerant edge:
|
||||
|
||||
| Mode | Behaviour | DoF |
|
||||
|------|-----------|-----|
|
||||
| Probe (no key) | measures P/T/SH/SC — **DoF-neutral** | 0 equations |
|
||||
| Spec | imposes **one** of `superheat_k`, `quality`, `t_c`/`t_k`, `p_bar` | **+1 equation** |
|
||||
|
||||
When imposing, free something elsewhere (emergent pressure, free actuator, free boundary).
|
||||
|
||||
### HeatSource (Heat.Source)
|
||||
|
||||
Injects `q_w` (or `q_kw`) into the stream energy balance:
|
||||
|
||||
```
|
||||
ṁ · (h_out − h_in) = Q_heat
|
||||
```
|
||||
|
||||
Negative Q extracts heat. Can be linked as `cold_component` of a thermal coupling (motor cooling pattern).
|
||||
|
||||
### Ports
|
||||
|
||||
Inline on a single branch (inlet/outlet pass-through).
|
||||
|
||||
### Calibration
|
||||
|
||||
None required for probe mode.
|
||||
|
||||
### JSON (main)
|
||||
|
||||
| Key | Component | Meaning |
|
||||
|-----|-----------|---------|
|
||||
| `superheat_k` / `quality` / `t_c` / `p_bar` | Anchor | one optional FIX |
|
||||
| `q_w` / `q_kw` | HeatSource | heat injection |
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### Anchor
|
||||
|
||||
Sonde (0 DoF) ou **une** spécification (+1 équation) : SH, x, T ou P.
|
||||
|
||||
### HeatSource
|
||||
|
||||
Injection de chaleur `Q` dans le bilan d’énergie du fluide.
|
||||
|
||||
### DoF
|
||||
|
||||
Imposer une spec Anchor ⇒ libérer ailleurs.
|
||||
94
apps/web/public/docs/components/boundaries.md
Normal file
94
apps/web/public/docs/components/boundaries.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Boundaries — Sources & Sinks / Frontières
|
||||
|
||||
Config types: `RefrigerantSource`, `RefrigerantSink`, `BrineSource`, `BrineSink`, `AirSource`, `AirSink`
|
||||
Sources: `refrigerant_boundary.rs`, `brine_boundary.rs`, `air_boundary.rs`
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
Boundary components fix **Dirichlet** conditions on one edge.
|
||||
**Source** = one outlet; **Sink** = one inlet. These are the natural place to **FIX** machine inputs (T, P, ṁ).
|
||||
|
||||
### RefrigerantSource / RefrigerantSink
|
||||
|
||||
```
|
||||
Source: P = P_set ; h = h(P, x) n ≈ 2
|
||||
Sink: P = P_back ; optional h if x set n ≈ 1–2
|
||||
```
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `fluid` | refrigerant | primary |
|
||||
| `p_set_bar` / `p_back_bar` | pressure | ~10 bar typical |
|
||||
| `quality` | vapor quality | 1.0 source |
|
||||
|
||||
### BrineSource / BrineSink (water / glycol)
|
||||
|
||||
```
|
||||
Source: P, h(T), optional ṁ_set n = 2 or 3
|
||||
Sink: P_back, optional T/h, ṁ n = 1–3
|
||||
```
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `fluid` | Water / MEG / … | Water |
|
||||
| `p_set_bar` / `p_back_bar` | pressure | 2 bar |
|
||||
| `t_set_c` | temperature | 12 °C (source) |
|
||||
| `concentration` | glycol mass % | 0 |
|
||||
| `m_flow_kg_s` | imposed loop flow (BOLT `Vd_fixed`) | optional |
|
||||
|
||||
**Do not** combine `m_flow_kg_s` with another flow imposition on the same branch (pump curve + fixed ṁ → over-constrained).
|
||||
|
||||
### AirSource / AirSink
|
||||
|
||||
Psychrometric state (Magnus–Tetens style humidity + moist air enthalpy):
|
||||
|
||||
```
|
||||
h ≈ 1006·T_c + W·(2.501e6 + 1860·T_c)
|
||||
Source: fix P, h(T, RH) n = 2
|
||||
```
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `t_dry_c` / `t_set_c` | dry-bulb | – |
|
||||
| `rh` | relative humidity | – |
|
||||
| `p_set_bar` | pressure | ~1 bar |
|
||||
| `m_flow_kg_s` | optional mass flow | – |
|
||||
|
||||
### System wiring for HX secondary
|
||||
|
||||
```
|
||||
BrineSource.outlet → HX.secondary_inlet
|
||||
HX.secondary_outlet → BrineSink.inlet
|
||||
```
|
||||
|
||||
Without live wiring, HX may still run in **rating** mode with scalar secondary_* on the HX itself.
|
||||
|
||||
### Calibration
|
||||
|
||||
Boundaries generally have **no Z-factors**. They are pure Fixed inputs / back-pressure.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### Rôle
|
||||
|
||||
Imposent les **conditions aux limites** (P, T, ṁ). C’est là qu’on **fixe** les entrées machine.
|
||||
|
||||
### Eau (Brine)
|
||||
|
||||
Source : P, T, ṁ optionnel. Sink : contre-pression (T sortie souvent **libre** = émergente).
|
||||
|
||||
### Air
|
||||
|
||||
État psychrométrique (T sèche, HR → h).
|
||||
|
||||
### Câblage HX
|
||||
|
||||
Source → secondary_in → secondary_out → Sink pour le mode système.
|
||||
|
||||
### JSON
|
||||
|
||||
Voir tableaux EN.
|
||||
119
apps/web/public/docs/components/bphx.md
Normal file
119
apps/web/public/docs/components/bphx.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# BphxEvaporator / BphxCondenser (Brazed Plate HX)
|
||||
|
||||
Config types: `"BphxEvaporator"`, `"BphxCondenser"`
|
||||
Source: `crates/components/src/heat_exchanger/bphx_evaporator.rs`, `bphx_condenser.rs`, shared geometry/correlation helpers
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Brazed-plate HX with **geometry + two-phase correlation → h → UA estimate**, then runtime solve on an **inner ε-NTU** residual model.
|
||||
|
||||
#### Correlations (selectable)
|
||||
|
||||
Default **Longo 2004**. Also **Shah 1979**, **Shah 2021**.
|
||||
|
||||
Full registry (also Kandlikar, Gungor–Winterton, Gnielinski, Dittus–Boelter, Ko 2021, Friedel ΔP): see [correlations-and-maps.md](./correlations-and-maps.md).
|
||||
|
||||
Equivalent Reynolds construction (schematic):
|
||||
|
||||
```
|
||||
Re_l = G · d_h / μ_l
|
||||
Re_eq = Re_l · (1 − x + x · √(ρ_l / ρ_v))
|
||||
```
|
||||
|
||||
Longo-style Nu (illustrative forms used in the implementation path):
|
||||
|
||||
```
|
||||
Evaporation: Nu ~ f(Re_eq, Pr_l) (e.g. 0.05 · Re_eq^0.8 · Pr_l^0.33)
|
||||
Condensation: Nu ~ f(Re_eq, Pr_l, ρ*) (e.g. 1.875 · Re_eq^0.35 · Pr_l^0.33 · …)
|
||||
h = Nu · k_l / d_h
|
||||
UA_est = h · A · z_ua
|
||||
```
|
||||
|
||||
Pressure drop (schematic):
|
||||
|
||||
```
|
||||
ΔP = z_dp · 2 · f · L · G² / (ρ · d_h)
|
||||
```
|
||||
|
||||
**Important:** the **Newton system residuals** for the component are the **inner ε-NTU** residual set (`n_equations` of the inner model, typically 2 for the base HX path). The correlation updates **UA** (when `update_ua_from_htc` / geometry path is engaged); it is **not** a full multi-zone moving-boundary residual stack.
|
||||
|
||||
### Modes / targets
|
||||
|
||||
| Type | Mode | Notes |
|
||||
|------|------|--------|
|
||||
| `BphxEvaporator` | **DX only** | Outlet is superheated vapor. `target_superheat_k` (default 5 K) is diagnostic/target storage — not a flooded shell model. For flooded shell-and-tube use `FloodedEvaporator`. |
|
||||
| `BphxCondenser` | Subcooling target | `target_subcooling_k` (default 3 K) |
|
||||
|
||||
### Ports
|
||||
|
||||
4-port Modelica-style naming in the system graph when wired:
|
||||
|
||||
| Port | Role |
|
||||
|------|------|
|
||||
| `inlet` / `outlet` | Refrigerant |
|
||||
| `secondary_inlet` / `secondary_outlet` | Secondary fluid |
|
||||
|
||||
Geometry fields: plate length/width, thickness, chevron, channel spacing, optional `dh_m` / `area_m2` overrides.
|
||||
|
||||
### Calibration
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `z_ua` / `Z_UA` | UA scale | **1.0** |
|
||||
| `z_dp` / `Z_dpc` | ΔP scale | **1.0** |
|
||||
| `ua` explicit | sets `z_ua = ua / UA_nom` | – |
|
||||
|
||||
Legacy `f_ua` / `f_dp` accepted in JSON.
|
||||
|
||||
### JSON parameters (main)
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `n_plates` | plate count | 20 |
|
||||
| `plate_length_m` / `plate_width_m` | geometry | – |
|
||||
| `chevron_angle_deg` | chevron | 60 |
|
||||
| `correlation` | Longo2004 / Shah1979 / Shah2021 | Longo2004 |
|
||||
| `target_superheat_k` | DX target (evap) | 5 K |
|
||||
| `target_subcooling_k` | SC target (cond) | 3 K |
|
||||
| `refrigerant` / `secondary_fluid` | fluids | – |
|
||||
| `z_ua`, `z_dp` | calib | 1.0 |
|
||||
|
||||
### DoF / system usage
|
||||
|
||||
Prefer live secondary wiring for closed loops. Pair `z_ua` free + measured SST/SDT for inverse calibration (same Fixed/Free discipline as other HX).
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But & modèle
|
||||
|
||||
Échangeurs **à plaques brasées** : géométrie + **corrélation biphasique** (Longo 2004 / Shah) → coefficient h → UA, puis solveur sur modèle **ε-NTU interne**.
|
||||
|
||||
Formes types :
|
||||
|
||||
```
|
||||
Re_eq = Re_l · (1 − x + x · √(ρ_l/ρ_v))
|
||||
Nu = f(Re_eq, Pr, …) # Longo / Shah selon `correlation`
|
||||
h = Nu · k / d_h
|
||||
UA = h · A · z_ua
|
||||
```
|
||||
|
||||
Le **Newton** ne résout pas la corrélation plaque par plaque : il résout le **HX ε-NTU** ; la corrélation **calibre/estime UA**.
|
||||
|
||||
### Modes
|
||||
|
||||
- **BphxEvaporator** : DX uniquement (pas un flooded shell).
|
||||
- **BphxCondenser** : cible de sous-refroidissement.
|
||||
|
||||
### Calibration
|
||||
|
||||
`z_ua = 1`, `z_dp = 1` par défaut. Alias BOLT `Z_UA`, `Z_dpc`.
|
||||
|
||||
### Ports / JSON
|
||||
|
||||
Voir tableaux EN.
|
||||
53
apps/web/public/docs/components/bypass-valve.md
Normal file
53
apps/web/public/docs/components/bypass-valve.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# BypassValve
|
||||
|
||||
Config type: `"BypassValve"`
|
||||
Source: `crates/components/src/bypass_valve.rs` (or valve module)
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Bypass leg valve with opening characteristic (linear / equal-percentage / custom). Parallel path around a component (compressor, HX, etc.).
|
||||
|
||||
```
|
||||
ṁ = f(opening, ΔP, kv, characteristic)
|
||||
h_out ≈ h_in
|
||||
```
|
||||
|
||||
### Residuals
|
||||
|
||||
Flow residual + energy (isenthalpic or low Δh).
|
||||
|
||||
### Ports
|
||||
|
||||
`inlet` / `outlet`.
|
||||
|
||||
### Actuator
|
||||
|
||||
`opening` ∈ [0, 1] — free when under control.
|
||||
|
||||
### JSON (main)
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `opening` | 0–1 | 0–1 |
|
||||
| `kv` | capacity | – |
|
||||
| characteristic | linear / … | linear |
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
**Vanne de by-pass** sur une branche parallèle.
|
||||
|
||||
### Actionneur
|
||||
|
||||
Ouverture 0–1.
|
||||
|
||||
### JSON
|
||||
|
||||
Voir EN.
|
||||
131
apps/web/public/docs/components/compressor-ahri540.md
Normal file
131
apps/web/public/docs/components/compressor-ahri540.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Compressor (AHRI 540 + maps)
|
||||
|
||||
Config type: `"Compressor"`
|
||||
Source: `crates/components/src/compressor.rs`
|
||||
Related: `polynomials.rs` (Polynomial2D), registry `SstSdt` model variant
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose
|
||||
|
||||
Positive-displacement compressor performance from **published coefficient maps**:
|
||||
|
||||
1. **AHRI 540** (CLI default for `"Compressor"`) — 10 coefficients M1–M10
|
||||
2. **SST/SDT polynomial** (API / registry) — 2D polynomials ṁ(SST,SDT), Ẇ(SST,SDT)
|
||||
|
||||
### Model A — AHRI 540
|
||||
|
||||
**Mass flow [kg/s]:**
|
||||
|
||||
```
|
||||
ṁ = M1 · (1 − (P_suc / P_dis)^(1/M2)) · ρ_suc · V_disp · N/60
|
||||
```
|
||||
|
||||
**Power cooling [W]:**
|
||||
|
||||
```
|
||||
Ẇ = M3 + M4 · (P_dis/P_suc) + M5 · T_suc + M6 · T_dis
|
||||
```
|
||||
|
||||
**Power heating [W]:**
|
||||
|
||||
```
|
||||
Ẇ = M7 + M8 · (P_dis/P_suc) + M9 · T_suc + M10 · T_dis
|
||||
```
|
||||
|
||||
| Coeff | Role | Typical CLI default |
|
||||
|-------|------|---------------------|
|
||||
| M1 | flow scale | 0.85 |
|
||||
| M2 | PR exponent (>0) | 2.5 |
|
||||
| M3–M6 | cooling power poly | 500, 1500, −2.5, 1.8 |
|
||||
| M7–M10 | heating power poly | 600, 1600, −3.0, 2.0 |
|
||||
|
||||
Also required: `speed_rpm`, `displacement_m3`, `efficiency` (isentropic / overall as used by the arm).
|
||||
|
||||
### Model B — SST/SDT polynomial (same `Compressor` type)
|
||||
|
||||
Select with JSON / UI: `"model_type": "SstSdt"` (aliases: `SstSdtPolynomial`, `sst_sdt`).
|
||||
|
||||
```
|
||||
ṁ = Σ a_ij · SST^i · SDT^j [kg/s] (SST, SDT in Kelvin)
|
||||
Ẇ = Σ b_ij · SST^i · SDT^j [W]
|
||||
```
|
||||
|
||||
Bilinear form (CLI / UI coefficients):
|
||||
|
||||
```
|
||||
ṁ = a00 + a10·SST + a01·SDT + a11·SST·SDT
|
||||
Ẇ = b00 + b10·SST + b01·SDT + b11·SST·SDT
|
||||
```
|
||||
|
||||
| JSON key | Role | Default (example) |
|
||||
|----------|------|-------------------|
|
||||
| `mf_a00` … `mf_a11` | mass-flow bilinear | 0.05, 0.001, 0.0005, 1e−5 |
|
||||
| `pw_b00` … `pw_b11` | power bilinear | 1000, 50, 30, 0.5 |
|
||||
|
||||
Also used by **ScrewEconomizerCompressor** (same bilinear form + eco fraction + presets Bitzer/Grasso).
|
||||
|
||||
### Residuals / ports
|
||||
|
||||
Two-port suction/discharge. Residual count depends on same-branch mass and model wiring (typically flow + energy).
|
||||
|
||||
### Calibration
|
||||
|
||||
| Factor | Default | Effect |
|
||||
|--------|---------|--------|
|
||||
| `z_flow` | **1.0** | scales ṁ |
|
||||
| `z_power` | **1.0** | scales Ẇ |
|
||||
|
||||
### JSON (CLI `"Compressor"`)
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `model_type` | `Ahri540` \| `SstSdt` | `Ahri540` |
|
||||
| `speed_rpm` | speed | **required** |
|
||||
| `displacement_m3` | displacement | **required** |
|
||||
| `efficiency` | efficiency | 0.85 |
|
||||
| `fluid` | refrigerant | required |
|
||||
| `m1` … `m10` | AHRI coeffs (if Ahri540) | see table |
|
||||
| `mf_a00`…`mf_a11`, `pw_b00`…`pw_b11` | SST/SDT bilinear (if SstSdt) | see table |
|
||||
| `p_suction_bar` / `h_suction_kj_kg` | init ports | 3.5 / 400 |
|
||||
| `p_discharge_bar` / `h_discharge_kj_kg` | init ports | 12 / 440 |
|
||||
|
||||
### UI guidance
|
||||
|
||||
- **Modèle de carte** : bascule Ahri540 ↔ SstSdt
|
||||
- Sections **AHRI 540** ou **Map SST/SDT** selon le choix
|
||||
- Section **Machine** : speed, displacement, efficiency
|
||||
- L’**IsentropicCompressor** est un modèle physique différent (η_is + cylindrée)
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
Compresseur à **cartes de performance** constructeur.
|
||||
|
||||
### AHRI 540
|
||||
|
||||
```
|
||||
ṁ = M1 · (1 − (P_s/P_d)^{1/M2}) · ρ · V · N/60
|
||||
Ẇ = M3 + M4·PR + M5·T_s + M6·T_d (froid)
|
||||
```
|
||||
|
||||
### Polynôme SST/SDT
|
||||
|
||||
```
|
||||
ṁ, Ẇ = polynôme 2D en SST et SDT
|
||||
```
|
||||
|
||||
(Utilisé surtout sur le **vis** ; presets Bitzer/Grasso.)
|
||||
|
||||
### Calibration
|
||||
|
||||
`z_flow`, `z_power` = **1.0** par défaut.
|
||||
|
||||
### JSON
|
||||
|
||||
Voir tableau EN (`m1`…`m10`, `speed_rpm`, `displacement_m3`).
|
||||
124
apps/web/public/docs/components/condenser.md
Normal file
124
apps/web/public/docs/components/condenser.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Condenser / CondenserCoil
|
||||
|
||||
Config types: `"Condenser"`, `"CondenserCoil"`
|
||||
Source: `crates/components/src/heat_exchanger/condenser.rs`
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & physical model
|
||||
|
||||
Refrigerant **condenser** rejecting heat to a secondary stream (water/glycol or air). Coupled duty is **phase-change ε-NTU** (isothermal refrigerant side at `T_cond(P)`):
|
||||
|
||||
```
|
||||
ε = 1 − exp(−UA_eff / C_sec)
|
||||
Q = ε · C_sec · (T_cond(P_in) − T_sec,in) # heat rejected by refrigerant
|
||||
```
|
||||
|
||||
- Optional lumped refrigerant ΔP: `ΔP = k · ṁ · |ṁ|`
|
||||
- `CondenserCoil` locks secondary side to **Air** conventions
|
||||
- **No plate correlation** here (see BPHX for Longo/Shah geometry UA)
|
||||
|
||||
`UA_eff` can be reduced by flooded-level actuator; `C_sec` can be scaled by fan speed φ when fan head-pressure is active.
|
||||
|
||||
### Dual secondary modes (Newton)
|
||||
|
||||
| Mode | Secondary source | `n_secondary` |
|
||||
|------|------------------|---------------|
|
||||
| **System** | Live edges ports 2/3 (`secondary_inlet` / `secondary_outlet`) | 1 or 2 |
|
||||
| **Rating** | Scalars `secondary_inlet_temp_*` + capacity rate / ṁ·cp | 0 |
|
||||
|
||||
`coupled_ready` requires refrigerant indices **and** (live edges **or** rating scalars).
|
||||
`live_secondary_stream` prefers edges; falls back to rating scalars (with fan φ scaling of `C_sec` when applicable).
|
||||
|
||||
### Residuals & `n_equations()` (coupled)
|
||||
|
||||
| Row | Equation |
|
||||
|-----|----------|
|
||||
| r0 | `P_out − (P_in − ΔP)` (skippable) |
|
||||
| r1 | `ṁ · (h_in − h_out) − Q` |
|
||||
| r2 (emergent) | `h_out − h(P, T_cond − SC)` subcooling closure |
|
||||
| r_mass | `ṁ_out − ṁ_in` if not same-branch |
|
||||
| r_head (optional) | `T_cond − T_target` (fan **or** flooded head-pressure) |
|
||||
| r_sec | live secondary mass/energy only if edges present |
|
||||
|
||||
```
|
||||
n_equations = n_thermo + (mass?) + (head?) + n_secondary
|
||||
n_thermo = 2 normally, 3 with emergent_pressure (+ subcooling residual)
|
||||
```
|
||||
|
||||
### Emergent pressure & actuators
|
||||
|
||||
- `emergent_pressure: true` + `subcooling_k` → condensing pressure is **solved**, not fixed by design T
|
||||
- **Fan head-pressure:** free φ scales `C_sec = φ · C_nominal`; residual pins `T_cond`
|
||||
- **Flooded head-pressure:** free level λ scales `UA_eff`; mutually exclusive with fan
|
||||
|
||||
### Ports
|
||||
|
||||
| Port | Index |
|
||||
|------|-------|
|
||||
| `inlet` / `outlet` | 0 / 1 refrigerant |
|
||||
| `secondary_inlet` / `secondary_outlet` | 2 / 3 secondary |
|
||||
|
||||
System wiring: Source → secondary_in → secondary_out → Sink.
|
||||
|
||||
### Calibration
|
||||
|
||||
| Factor | Meaning | Default |
|
||||
|--------|---------|---------|
|
||||
| `z_ua` | UA scale | **1.0** |
|
||||
| `z_dp` | ΔP scale | 1.0 |
|
||||
| `z_flow` / `z_power` / `z_etav` | via shared Calib API | 1.0 |
|
||||
|
||||
UI: Fixed on SDT target + free `z_ua` for inverse calibration.
|
||||
|
||||
### JSON parameters (main)
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `ua` | UA [W/K] | required |
|
||||
| `emergent_pressure` | free P_cond | false |
|
||||
| `subcooling_k` | outlet SC [K] | 5 |
|
||||
| `secondary_fluid` | Water / Air / … | – |
|
||||
| `secondary_inlet_temp_c` / mass_flow / cp | rating stream | – |
|
||||
| `pressure_drop_coeff` | k for ΔP | – |
|
||||
| `fan_head_pressure_target_c` | fan control | – |
|
||||
| `flooded_head_pressure_target_c` | level control | – |
|
||||
| `skip_pressure_eq` | drop r0 | false |
|
||||
|
||||
### Zero flow
|
||||
|
||||
Live `C_sec` uses `smooth_mass_magnitude(|ṁ|)`. Mass-flow index never remapped to a pressure column.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But & modèle
|
||||
|
||||
Condenseur frigo → secondaire (eau/air). Duty **ε-NTU** :
|
||||
|
||||
```
|
||||
Q = ε · C_sec · (T_cond(P) − T_sec,in)
|
||||
```
|
||||
|
||||
Pas de corrélation plaques (voir BPHX). UA global ± actionneurs fan/niveau.
|
||||
|
||||
### Modes secondaire
|
||||
|
||||
- **Système :** ports live Source/Sink
|
||||
- **Rating :** scalaires T + ṁ·cp **dans le Newton** (pas seulement `rate()`)
|
||||
|
||||
### Pression émergente
|
||||
|
||||
`emergent_pressure` + sous-refroidissement : `P_cond` est **calculée**.
|
||||
Fan ou flooded head-pressure = +1 actionneur libre.
|
||||
|
||||
### Calibration
|
||||
|
||||
`z_ua = 1` par défaut. Imposer SDT + libérer Z_UA pour caler le condenseur.
|
||||
|
||||
### Ports / JSON
|
||||
|
||||
Voir tableaux EN.
|
||||
143
apps/web/public/docs/components/correlations-and-maps.md
Normal file
143
apps/web/public/docs/components/correlations-and-maps.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Correlations & performance maps / Corrélations & cartes
|
||||
|
||||
Master inventory of **every performance map and heat-transfer / pressure-drop correlation** wired in Entropyk (as of 2026-07-17).
|
||||
Inventaire de **toutes** les cartes et corrélations du code.
|
||||
|
||||
Sources principales :
|
||||
|
||||
- `crates/components/src/compressor.rs` — AHRI 540 + SST/SDT
|
||||
- `crates/components/src/screw_economizer_compressor.rs` — polynômes 2D + presets
|
||||
- `crates/components/src/isentropic_compressor.rs` — η_is + volumétrique
|
||||
- `crates/components/src/polynomials.rs` — Polynomial1D / Polynomial2D
|
||||
- `crates/components/src/heat_exchanger/bphx_correlation.rs` — formules h
|
||||
- `crates/components/src/heat_exchanger/correlation_registry.rs` — catalogue + domaines
|
||||
- `crates/components/src/heat_exchanger/eps_ntu.rs` / `lmtd.rs` — HX génériques
|
||||
- `crates/components/src/heat_exchanger/two_phase_dp.rs` — ΔP biphasique
|
||||
- `crates/components/src/fan.rs` / `pump.rs` — courbes 1D
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### 1. Compressors — performance maps
|
||||
|
||||
| Component | Model ID | Formula (summary) | Inputs | Outputs | UI / JSON |
|
||||
|-----------|----------|-------------------|--------|---------|-----------|
|
||||
| **IsentropicCompressor** | Physics + η_is | `h_dis = h_suc + (h_is−h_suc)/η_is` ; emergent: `ṁ = ρ·V_d·N·η_vol·z_flow` | η_is, T guesses, V_d, N | ṁ, h_dis, W | η, emergent, displacement, speed |
|
||||
| **Compressor** | **AHRI 540** (`model_type=Ahri540`) | `ṁ = M1·(1−(P_s/P_d)^{1/M2})·ρ·V·N/60` ; `Ẇ_cool = M3+M4·PR+M5·T_s+M6·T_d` (heating M7–M10) | M1…M10, V, N | ṁ, Ẇ | `m1`…`m10`, `speed_rpm`, `displacement_m3` |
|
||||
| **Compressor** | **SST/SDT poly** (`model_type=SstSdt`) | `ṁ = a00+a10·SST+a01·SDT+a11·SST·SDT` ; same for Ẇ with `pw_b**` | bilinear coeffs | ṁ, Ẇ | `mf_a**`, `pw_b**` (CLI + UI) |
|
||||
| **ScrewEconomizerCompressor** | **Bilinear SST/SDT** | `ṁ_suc = z_flow·(a00+a10·SST+a01·SDT+a11·SST·SDT)` ; same for Ẇ with b_ij ; eco fraction poly | presets + overrides | ṁ_suc, ṁ_eco, Ẇ | `preset`, `mf_a**`, `pw_b**` |
|
||||
|
||||
#### Screw presets (CLI)
|
||||
|
||||
| Preset | ṁ (a00,a10,a01,a11) | Power (b00,b10,b01,b11) | eco frac |
|
||||
|--------|---------------------|-------------------------|----------|
|
||||
| `bitzer_generic_200kw` | 1.35, 0.004, −0.0025, 1.2e−5 | 58000, 180, −280, 0.4 | 0.13 |
|
||||
| `grasso_generic_200kw` | 1.30, 0.0035, −0.0022, 1e−5 | 60000, 190, −310, 0.45 | 0.11 |
|
||||
| (none) | 1.2, 0.003, −0.002, 1e−5 | 55000, 200, −300, 0.5 | 0.12 |
|
||||
|
||||
Temps in polynomials: **SST / SDT** as used by the curve implementation (see source; typically °C in manufacturer fits — verify against `Polynomial2D` evaluation units in code).
|
||||
|
||||
#### Calibration Z on compressors
|
||||
|
||||
| Factor | Effect |
|
||||
|--------|--------|
|
||||
| `z_flow` | scales ṁ |
|
||||
| `z_flow_eco` | scales economizer ṁ (screw) |
|
||||
| `z_power` | scales shaft power |
|
||||
| `z_etav` | volumetric efficiency correction |
|
||||
|
||||
Default all **1.0**.
|
||||
|
||||
---
|
||||
|
||||
### 2. Heat exchangers — heat transfer correlations
|
||||
|
||||
| Correlation ID | Year | Purpose | Geometry | Wired in BPHX UI? |
|
||||
|----------------|------|---------|----------|-------------------|
|
||||
| **Longo2004** | 2004 | Evap / cond HTC (plates) | Brazed plate | **Yes** (default) |
|
||||
| **Shah1979** | 1979 | Condensation HTC | Tubes (also selectable) | **Yes** |
|
||||
| **Shah2021** | 2021 | Plate condensation | Plates | **Yes** |
|
||||
| Kandlikar1990 | 1990 | Evaporation HTC | Tubes | Registry / BPHX enum |
|
||||
| GungorWinterton1986 | 1986 | Evaporation HTC | Tubes | Registry |
|
||||
| Gnielinski1976 | 1976 | Single-phase turbulent Nu | Tubes | Registry |
|
||||
| DittusBoelter1930 | 1930 | Single-phase Nu (simple) | Tubes | Registry |
|
||||
| Ko2021 | 2021 | Low-GWP plates | Plates | Registry |
|
||||
| Friedel1979 | 1979 | Two-phase ΔP | Tubes/plates | Registry (ΔP) |
|
||||
|
||||
**BPHX runtime path:** correlation → h → `UA ≈ h·A·z_ua` → **ε-NTU residuals** (not a full multi-zone MB model).
|
||||
|
||||
**Condenser / Evaporator / FloodedEvaporator:** **no** plate correlation — **lumped UA** + phase-change ε-NTU:
|
||||
|
||||
```
|
||||
ε = 1 − exp(−UA/C_sec)
|
||||
Q = ε · C_sec · ΔT_driving
|
||||
```
|
||||
|
||||
**Generic HeatExchanger:** ε-NTU or LMTD (arrangement-dependent).
|
||||
**Economizer (internal):** LMTD-style two-stream.
|
||||
**MovingBoundaryHX:** multi-zone research path (not default production).
|
||||
|
||||
---
|
||||
|
||||
### 3. Pressure drop
|
||||
|
||||
| Model | Formula / role | Components |
|
||||
|-------|----------------|------------|
|
||||
| Quadratic refrigerant | `ΔP = k · ṁ · \|ṁ\|` | Condenser, Evaporator (optional) |
|
||||
| BPHX friction | `ΔP = z_dp · 2·f·L·G²/(ρ·d_h)` (implementation path) | BPHX |
|
||||
| Friedel 1979 | two-phase ΔP (registry) | selection stack |
|
||||
| Pipe Darcy-style | f(L,D,ε,Re,ṁ) | Pipe |
|
||||
| Valve orifice | `ṁ = Kv·opening·√(2·ρ·ΔP)` | EXV orifice, BypassValve |
|
||||
|
||||
---
|
||||
|
||||
### 4. Pumps & fans — 1D polynomials
|
||||
|
||||
```
|
||||
y = c0 + c1·x + c2·x² + … (Polynomial1D)
|
||||
```
|
||||
|
||||
- **Pump:** head H(Q), efficiency η(Q); affinity laws for speed.
|
||||
- **Fan:** static pressure / power vs flow and speed.
|
||||
|
||||
---
|
||||
|
||||
### 5. Flow regularization (not a HTC correlation)
|
||||
|
||||
Smooth `|ṁ|`, activity α, duty blend — keeps Newton finite at zero flow. See [flow-regularization.md](./flow-regularization.md).
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### Compresseurs
|
||||
|
||||
| Composant | Modèle | Formule clé |
|
||||
|-----------|--------|-------------|
|
||||
| Isentropic | Physique + η_is | h_dis isentropique corrigé ; ṁ = ρ V N η_vol |
|
||||
| Compressor | **AHRI 540** M1–M10 | ṁ(P,ρ,V,N) ; Ẇ(PR, T) |
|
||||
| Screw | **Polynôme bilinéaire SST/SDT** | ṁ, W = a00+a10·SST+a01·SDT+a11·SST·SDT |
|
||||
|
||||
Presets vis : Bitzer / Grasso génériques 200 kW (coeffs dans CLI).
|
||||
|
||||
### Échangeurs — corrélations h
|
||||
|
||||
| Corrélation | Usage |
|
||||
|-------------|--------|
|
||||
| Longo 2004 | BPHX défaut évap/cond plaques |
|
||||
| Shah 1979 / 2021 | condensation (tubes / plaques) |
|
||||
| Kandlikar, Gungor–Winterton | évaporation tubes (registre) |
|
||||
| Gnielinski, Dittus–Boelter | monophasique |
|
||||
| Ko 2021 | plaques low-GWP |
|
||||
| Friedel 1979 | ΔP biphasique |
|
||||
|
||||
**Condenser / Evaporator / Flooded :** **UA global + ε-NTU** (pas Longo).
|
||||
|
||||
### Pompes / ventilateurs
|
||||
|
||||
Polynômes 1D Q–H / Q–η + lois d’affinité.
|
||||
|
||||
### Calibration
|
||||
|
||||
Tous les Z par défaut **1.0** (pas de correction).
|
||||
62
apps/web/public/docs/components/drum.md
Normal file
62
apps/web/public/docs/components/drum.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Drum (separator / recirculation drum)
|
||||
|
||||
Config type: `"Drum"`
|
||||
Source: `crates/components/src/drum.rs`
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Liquid/vapor **separator** used in flooded recirculation architectures. Splits a two-phase feed into liquid and vapor outlets; may accept an evaporator return.
|
||||
|
||||
Thermodynamics: equilibrium separation at drum pressure (quality split toward x≈0 liquid / x≈1 vapor legs), mass and energy balances across ports.
|
||||
|
||||
### Residuals & `n_equations()`
|
||||
|
||||
Multi-port balance residuals (mass + energy + pressure consistency). Exact count depends on active ports and edge wiring; treat as a multi-equation node — see unit tests and `n_equations()` in source.
|
||||
|
||||
### Ports (4-port naming)
|
||||
|
||||
| Port | Role |
|
||||
|------|------|
|
||||
| `feed_inlet` | two-phase feed |
|
||||
| `evaporator_return` | return from flooded evaporator |
|
||||
| `liquid_outlet` | liquid to pump / recirculation |
|
||||
| `vapor_outlet` | vapor to compressor suction |
|
||||
|
||||
### Calibration
|
||||
|
||||
No primary Z-factor set; geometry/level control may be added in specialized builds.
|
||||
|
||||
### JSON (main)
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `fluid` / refrigerant | working fluid | primary |
|
||||
| level / volume options | if exposed | – |
|
||||
|
||||
### System note
|
||||
|
||||
A **flooded plate** topology is often Drum + recirculation + DX exchanger — not a mode of BPHX alone. Shell-and-tube flooded use `FloodedEvaporator`.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But & modèle
|
||||
|
||||
**Ballon séparateur** liquide/vapeur pour architectures noyées / recirculation.
|
||||
|
||||
### Ports
|
||||
|
||||
Alimentation, retour évap, sortie liquide, sortie vapeur.
|
||||
|
||||
### DoF
|
||||
|
||||
Nœud multi-équations ; équilibrer avec le reste du circuit.
|
||||
|
||||
### JSON
|
||||
|
||||
Voir EN.
|
||||
37
apps/web/public/docs/components/economizer.md
Normal file
37
apps/web/public/docs/components/economizer.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Economizer (internal)
|
||||
|
||||
Source: economizer HX used inside screw-economizer circuits (`HeatExchanger<LmtdModel>` patterns)
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Internal heat exchanger that subcools liquid / evaporates injection gas for economized screw cycles. Often **not** a standalone CLI leaf — instantiated inside compressor economizer plumbing or macro components.
|
||||
|
||||
Model: LMTD or ε-NTU between liquid line and eco vapor.
|
||||
|
||||
### Residuals
|
||||
|
||||
Standard two-stream HX residuals of the inner model.
|
||||
|
||||
### Ports
|
||||
|
||||
Hot/cold legs as wired by the parent circuit.
|
||||
|
||||
### Note
|
||||
|
||||
For user-facing machines, configure economizer via **ScrewEconomizerCompressor** + circuit topology rather than a free-floating Economizer node unless the CLI arm exposes it.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
Échangeur **économiseur** interne (sous-refroidissement / injection).
|
||||
|
||||
### Note
|
||||
|
||||
Souvent intégré au circuit vis, pas un composant CLI autonome.
|
||||
123
apps/web/public/docs/components/evaporator.md
Normal file
123
apps/web/public/docs/components/evaporator.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Evaporator / EvaporatorCoil
|
||||
|
||||
Config types: `"Evaporator"`, `"EvaporatorCoil"`
|
||||
Source: `crates/components/src/heat_exchanger/evaporator.rs`
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & physical model
|
||||
|
||||
**DX (direct-expansion)** evaporator: refrigerant outlet is **superheated vapor** (not flooded two-phase). Phase-change ε-NTU against a hot secondary stream:
|
||||
|
||||
```
|
||||
ε = 1 − exp(−UA / C_sec)
|
||||
Q = ε · C_sec · (T_sec,in − T_evap(P)) # heat absorbed by refrigerant
|
||||
```
|
||||
|
||||
- Optional refrigerant ΔP = k·ṁ·|ṁ|
|
||||
- `EvaporatorCoil` locks secondary side to **Air**
|
||||
- **No plate correlation** (see BPHX / Longo–Shah for geometry-based UA)
|
||||
|
||||
Difference vs `FloodedEvaporator`: DX uses **superheat closure** (or regulated SH); flooded uses **saturated vapor** (or quality) by default.
|
||||
|
||||
### Dual secondary modes (Newton)
|
||||
|
||||
| Mode | Secondary | `n_secondary` |
|
||||
|------|-----------|---------------|
|
||||
| **System** | Live `secondary_inlet` / `secondary_outlet` | 1 or 2 |
|
||||
| **Rating** | Scalars T_sec + C_sec (ṁ·cp) | 0 |
|
||||
|
||||
`coupled_ready` = refrigerant ready **and** (live edges **or** rating scalars).
|
||||
`live_secondary_stream` = edges first, else rating scalars.
|
||||
|
||||
### Residuals & `n_equations()` (coupled emergent)
|
||||
|
||||
| Row | Equation |
|
||||
|-----|----------|
|
||||
| r0 | `P_out − (P_in − ΔP)` (optional skip) |
|
||||
| r1 | `ṁ · (h_out − h_in) − Q` |
|
||||
| r2 | `h_out − h(P, T_evap+SH)` if superheat is imposed |
|
||||
| r_mass | dropped if same-branch |
|
||||
| r_sec | live secondary mass/energy if edges |
|
||||
|
||||
```
|
||||
n_thermo = base (1 or 2) + 1 if imposes_superheat()
|
||||
n_equations = n_thermo + mass? + n_secondary
|
||||
```
|
||||
|
||||
### Superheat regulation (DoF)
|
||||
|
||||
| Setting | Effect |
|
||||
|---------|--------|
|
||||
| Default | SH residual active (`superheat_k` target) when emergent |
|
||||
| `superheat_regulated: true` | **Drops** SH residual (−1 eq) |
|
||||
|
||||
If SH residual is dropped, pair with a **free** EXV opening (and usually a control loop) so the system stays square. CLI DoF gate enforces balance.
|
||||
|
||||
### Ports
|
||||
|
||||
| Port | Index |
|
||||
|------|-------|
|
||||
| `inlet` / `outlet` | 0 / 1 refrigerant |
|
||||
| `secondary_inlet` / `secondary_outlet` | 2 / 3 secondary |
|
||||
|
||||
### Calibration
|
||||
|
||||
| Factor | Default | Notes |
|
||||
|--------|---------|-------|
|
||||
| `z_ua` | **1.0** | UA scale |
|
||||
| `z_dp` | 1.0 | ΔP scale |
|
||||
|
||||
UI Fixed: SST (`saturationTemperature`) + free `z_ua` for inverse calib.
|
||||
|
||||
### JSON parameters (main)
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `ua` | UA [W/K] | required |
|
||||
| `emergent_pressure` | free P_evap | false |
|
||||
| `superheat_k` | SH target [K] | 5 |
|
||||
| `superheat_regulated` | drop SH residual | false |
|
||||
| `secondary_fluid` / `secondary_*` | system edges or rating | – |
|
||||
| `skip_pressure_eq` | drop ΔP residual | false |
|
||||
|
||||
### energy_transfers
|
||||
|
||||
Coupled: `Q = ṁ·(h_out − h_in)` as positive heat (cooling capacity).
|
||||
|
||||
### Zero flow
|
||||
|
||||
Smooth `|ṁ|` for live `C_sec`; no silent mass-index→pressure fallback.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But & modèle
|
||||
|
||||
Évaporateur **DX** (sortie **surchauffée**). Duty ε-NTU :
|
||||
|
||||
```
|
||||
Q = ε · C_sec · (T_sec,in − T_evap(P))
|
||||
```
|
||||
|
||||
Différence avec **FloodedEvaporator** : clôture **superheat**, pas vapeur saturée noyée.
|
||||
|
||||
### Modes secondaire
|
||||
|
||||
- **Système :** ports live
|
||||
- **Rating :** scalaires T + ṁ·cp **dans le Newton**
|
||||
|
||||
### Régulation de surchauffe
|
||||
|
||||
`superheat_regulated: true` enlève le résidu SH → **libérer** l’ouverture EXV (contrôle).
|
||||
|
||||
### Calibration
|
||||
|
||||
`z_ua = 1` par défaut. Fixed SST + Z_UA libre pour calage.
|
||||
|
||||
### Ports / JSON
|
||||
|
||||
Voir EN.
|
||||
158
apps/web/public/docs/components/expansion-valve.md
Normal file
158
apps/web/public/docs/components/expansion-valve.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# ExpansionValve (legacy, port-based)
|
||||
|
||||
Config type: `"ExpansionValve"`
|
||||
Source: `crates/components/src/expansion_valve.rs`
|
||||
|
||||
> Distinct from `IsenthalpicExpansionValve` / `EXV`. Port-object based, with On/Off/Bypass operational states.
|
||||
> Distinct de `IsenthalpicExpansionValve` / `EXV`. Basé sur objets Port, avec états On/Off/Bypass.
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & physical model
|
||||
|
||||
2-port isenthalpic throttling valve for refrigeration systems:
|
||||
|
||||
```
|
||||
h_out = h_in (isenthalpic)
|
||||
ṁ_out = ṁ_in (mass continuity, with z_flow scale)
|
||||
P_out < P_in (throttling — pressure not closed by a flow law here)
|
||||
Q = 0, W = 0 (adiabatic, no work)
|
||||
```
|
||||
|
||||
Operational states:
|
||||
|
||||
| State | Behaviour |
|
||||
|-------|-----------|
|
||||
| **On** | isenthalpy + mass continuity |
|
||||
| **Off** | zero mass flow (`opening` < 0.01 also forces off) |
|
||||
| **Bypass** | adiabatic pipe: `P_out = P_in`, `h_out = h_in` |
|
||||
|
||||
`opening` does **not** enter the On residual set as a continuous flow coefficient; it only gates `is_effectively_off` below a 1 % threshold. For a free continuous opening + orifice law, use `IsenthalpicExpansionValve` with `orifice_kv`.
|
||||
|
||||
### Residuals & `n_equations()`
|
||||
|
||||
```
|
||||
n_equations = 2 (always)
|
||||
local state: state[0]=ṁ_in, state[1]=ṁ_out
|
||||
```
|
||||
|
||||
| Row | On | Off | Bypass |
|
||||
|-----|----|-----|--------|
|
||||
| r0 | `h_out − h_in` | `ṁ_in = 0` | `P_out − P_in` (with isenthalpy pairing) |
|
||||
| r1 | `ṁ_out − z_flow·ṁ_in` | 0 | `h_out − h_in` |
|
||||
|
||||
### Ports
|
||||
|
||||
| Role | Description |
|
||||
|------|-------------|
|
||||
| inlet | high pressure, typically subcooled liquid |
|
||||
| outlet | low pressure, typically two-phase |
|
||||
|
||||
Type-state: `ExpansionValve<Disconnected>` → `.connect()` → `ExpansionValve<Connected>`.
|
||||
|
||||
### Calibration
|
||||
|
||||
| Factor | Effect | Default |
|
||||
|--------|--------|---------|
|
||||
| `z_flow` | `ṁ_eff = z_flow · ṁ_in` | **1.0** |
|
||||
|
||||
`set_calib_indices` supports a dynamic `z_flow` state index.
|
||||
|
||||
### Emergent pressure / orifice
|
||||
|
||||
**Not available** on this component. Prefer `"IsenthalpicExpansionValve"` / `"EXV"`.
|
||||
|
||||
### energy_transfers
|
||||
|
||||
`(Q, W) = (0, 0)` always.
|
||||
|
||||
### JSON parameters
|
||||
|
||||
| Key | Meaning | Unit | Default |
|
||||
|-----|---------|------|---------|
|
||||
| `fluid` | refrigerant | – | **required** |
|
||||
| `opening` | valve position (off if < 0.01) | – | 1.0 |
|
||||
| `p_inlet_bar` / `h_inlet_kj_kg` | inlet IC | bar / kJ/kg | 12.0 / 260.0 |
|
||||
| `p_outlet_bar` / `h_outlet_kj_kg` | outlet IC | bar / kJ/kg | 3.5 / 260.0 |
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Legacy port-based residual path; less integrated with CM1.4 edge ṁ sharing than EXV.
|
||||
- No emergent-pressure or orifice actuator.
|
||||
- Production cycles should use `IsenthalpicExpansionValve`.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But & modèle physique
|
||||
|
||||
Vanne de laminage isenthalpique 2-port :
|
||||
|
||||
```
|
||||
h_out = h_in
|
||||
ṁ_out = ṁ_in (avec échelle z_flow)
|
||||
Q = 0, W = 0
|
||||
```
|
||||
|
||||
États opérationnels :
|
||||
|
||||
| État | Comportement |
|
||||
|------|--------------|
|
||||
| **On** | isenthalpie + continuité de masse |
|
||||
| **Off** | débit nul (`opening` < 0.01 force aussi l'arrêt) |
|
||||
| **Bypass** | tube adiabatique : `P_out = P_in`, `h_out = h_in` |
|
||||
|
||||
`opening` ne rentre **pas** dans les résidus On comme coefficient de débit continu ; il ne sert qu'au seuil d'arrêt. Pour une ouverture libre + orifice, utiliser `IsenthalpicExpansionValve` avec `orifice_kv`.
|
||||
|
||||
### Résiduels & `n_equations()`
|
||||
|
||||
```
|
||||
n_equations = 2 (toujours)
|
||||
état local : state[0]=ṁ_in, state[1]=ṁ_out
|
||||
```
|
||||
|
||||
| Ligne | On | Off | Bypass |
|
||||
|-------|----|-----|--------|
|
||||
| r0 | `h_out − h_in` | `ṁ_in = 0` | `P_out − P_in` |
|
||||
| r1 | `ṁ_out − z_flow·ṁ_in` | 0 | `h_out − h_in` |
|
||||
|
||||
### Ports
|
||||
|
||||
| Rôle | Description |
|
||||
|------|-------------|
|
||||
| entrée | haute pression, liquide sous-refroidi typique |
|
||||
| sortie | basse pression, biphasique typique |
|
||||
|
||||
Typestate : `Disconnected` → `.connect()` → `Connected`.
|
||||
|
||||
### Calibration
|
||||
|
||||
| Facteur | Effet | Défaut |
|
||||
|---------|-------|--------|
|
||||
| `z_flow` | `ṁ_eff = z_flow · ṁ_in` | **1.0** |
|
||||
|
||||
### Pression émergente / orifice
|
||||
|
||||
**Non disponibles.** Préférer `"IsenthalpicExpansionValve"` / `"EXV"`.
|
||||
|
||||
### energy_transfers
|
||||
|
||||
`(Q, W) = (0, 0)` toujours.
|
||||
|
||||
### Paramètres JSON
|
||||
|
||||
| Clé | Signification | Unité | Défaut |
|
||||
|-----|---------------|-------|--------|
|
||||
| `fluid` | frigorigène | – | **requis** |
|
||||
| `opening` | position (off si < 0.01) | – | 1.0 |
|
||||
| `p_inlet_bar` / `h_inlet_kj_kg` | CI entrée | bar / kJ/kg | 12.0 / 260.0 |
|
||||
| `p_outlet_bar` / `h_outlet_kj_kg` | CI sortie | bar / kJ/kg | 3.5 / 260.0 |
|
||||
|
||||
### Limites connues
|
||||
|
||||
- Chemin legacy port-object, moins intégré au partage ṁ CM1.4 que l'EXV.
|
||||
- Pas de pression émergente ni d'actionneur orifice.
|
||||
- Les cycles de production doivent utiliser `IsenthalpicExpansionValve`.
|
||||
49
apps/web/public/docs/components/fan.md
Normal file
49
apps/web/public/docs/components/fan.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Fan
|
||||
|
||||
Config type: `"Fan"`
|
||||
Source: `crates/components/src/fan.rs`
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Air-moving machine with **performance curves** (pressure rise / power vs flow and speed). Typestate ports: disconnected → connected.
|
||||
|
||||
Affinity laws may scale curves with rotational speed.
|
||||
|
||||
### Residuals & `n_equations()`
|
||||
|
||||
Curve residuals linking ΔP, ṁ (or volume flow), and speed; energy/power residual when power is modeled. See `n_equations()` in source (typically small fixed count for the fan node).
|
||||
|
||||
### Ports
|
||||
|
||||
`inlet` / `outlet` on the air branch.
|
||||
|
||||
### Calibration
|
||||
|
||||
Curve multipliers / Z-style factors when exposed via calib API (default unity).
|
||||
|
||||
### JSON (main)
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| curve data / preset | performance map | required |
|
||||
| `speed` / ratio | operating speed | 1.0 full |
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But & modèle
|
||||
|
||||
**Ventilateur** sur courbes ΔP / débit / vitesse.
|
||||
|
||||
### Ports
|
||||
|
||||
Entrée / sortie air.
|
||||
|
||||
### JSON
|
||||
|
||||
Voir EN.
|
||||
46
apps/web/public/docs/components/fin-coil-condenser.md
Normal file
46
apps/web/public/docs/components/fin-coil-condenser.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# FinCoilCondenser
|
||||
|
||||
Config type: `"FinCoilCondenser"`
|
||||
Source: finned-coil condenser geometry module
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Finned-tube outdoor coil. Geometry (tubes, rows, fin pitch, face velocity) feeds air-side heat transfer estimates; refrigerant side condenses with subcooling target options.
|
||||
|
||||
Correlations: coil/fin air-side Nu–Re style relations as implemented in the geometry stack (see source for exact correlation names).
|
||||
|
||||
### Residuals
|
||||
|
||||
HX residual set analogous to Condenser + coil geometry parameters for UA construction.
|
||||
|
||||
### Ports
|
||||
|
||||
Refrigerant + air secondary ports.
|
||||
|
||||
### Calibration
|
||||
|
||||
`z_ua` / geometry scales — default unity.
|
||||
|
||||
### JSON (main)
|
||||
|
||||
Tube OD, rows, fin density, face velocity, OAT, design capacity — see componentMeta FinCoilCondenser params.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
**Batterie ailetée** de condensation air.
|
||||
|
||||
### Corrélations
|
||||
|
||||
Côté air basées géométrie (détail dans le code).
|
||||
|
||||
### JSON
|
||||
|
||||
Voir meta UI / CLI.
|
||||
47
apps/web/public/docs/components/flooded-condenser.md
Normal file
47
apps/web/public/docs/components/flooded-condenser.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# FloodedCondenser
|
||||
|
||||
Rust / SystemBuilder type: `FloodedCondenser`
|
||||
Source: `crates/components/src/heat_exchanger/flooded_condenser.rs`
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Flooded condenser on an inner `HeatExchanger<EpsNtuModel>` with optional **subcooling control** residual:
|
||||
|
||||
```
|
||||
SC = (h_f(P) − h_out) / cp_l # when subcooled
|
||||
r_SC = SC − SC_target
|
||||
```
|
||||
|
||||
### Residuals & `n_equations()`
|
||||
|
||||
Base ≈ 3 (inner HX path); **+1** with subcooling control (default target ~5 K).
|
||||
|
||||
> **Status:** Prefer production **`Condenser` + `emergent_pressure`** for water-cooled machines. FloodedCondenser may lag the dual-mode / DoF discipline of FloodedEvaporator — treat as partial until fully aligned.
|
||||
|
||||
### Ports
|
||||
|
||||
Refrigerant + secondary via inner exchanger / 4-port names when wired.
|
||||
|
||||
### Calibration
|
||||
|
||||
Inner calib `z_ua` (default 1.0) when exposed.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
Condenseur **noyé** avec option de contrôle de sous-refroidissement.
|
||||
|
||||
### Statut
|
||||
|
||||
Préférer **`Condenser` + pression émergente** en production. Fiche partielle tant que le DoF n’est pas aligné sur FloodedEvaporator.
|
||||
|
||||
### Calibration
|
||||
|
||||
Z_UA = 1 si exposé.
|
||||
149
apps/web/public/docs/components/flooded-evaporator.md
Normal file
149
apps/web/public/docs/components/flooded-evaporator.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# FloodedEvaporator
|
||||
|
||||
Config type: `"FloodedEvaporator"`
|
||||
Source: `crates/components/src/heat_exchanger/flooded_evaporator.rs`
|
||||
Example: `crates/cli/examples/chiller_flooded_4port_watercooled.json` (DoF 19=19, COP ≈ 6.45)
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & physical model
|
||||
|
||||
Shell-and-tube **flooded** evaporator. Refrigerant boils on the shell side; secondary (water/brine) flows in the tubes. Heat duty uses **phase-change ε-NTU** (`C_min = C_sec`, `C_r → 0`):
|
||||
|
||||
```
|
||||
ε = 1 − exp(−UA / C_sec)
|
||||
Q = ε · C_sec · (T_sec,in − T_evap(P))
|
||||
```
|
||||
|
||||
- `T_evap(P)` = saturation temperature of the refrigerant at edge pressure
|
||||
- `C_sec` = secondary heat-capacity rate [W/K]
|
||||
|
||||
There is **no plate-geometry correlation** inside this component (unlike BPHX). UA is a **lumped parameter** (possibly scaled by calibration `z_ua` via the inner `HeatExchanger` calib API).
|
||||
|
||||
### Dual operating modes (both enter Newton residuals)
|
||||
|
||||
| Mode | How secondary is defined | Secondary Newton unknowns | When to use |
|
||||
|------|--------------------------|---------------------------|-------------|
|
||||
| **System (4-port)** | Live edges `secondary_inlet` / `secondary_outlet` (e.g. BrineSource → HX → BrineSink) | Yes (`n_secondary` = 1 or 2) | Closed water loop, real machine |
|
||||
| **Rating** | Scalars `secondary_inlet_temp_*` + `C_sec` (`secondary_mass_flow_kg_s` × `cp` or `secondary_capacity_rate_w_per_k`) | No (`n_secondary` = 0) | Qualification / open-loop duty; still **coupled ε-NTU in residuals** |
|
||||
|
||||
`coupled_ready` = refrigerant indices ready **and** (live secondary edges **or** rating scalars).
|
||||
Never falls through to generic four-port `HeatExchanger::inner` residuals for normal operation (seed path is local and finite).
|
||||
|
||||
**Rating residual energy:** uses full `Q` (not `α(ṁ)·Q`) so `ṁ = 0` is not a trivial root when `C_sec > 0`.
|
||||
**System residual energy:** uses `effective_duty(Q, α_ref, α_sec)` from `flow_regularization` for zero-flow safety.
|
||||
|
||||
Also exposed: `rate(p_in)` open-loop rating API for sweeps (same ε-NTU formulas).
|
||||
|
||||
### Outlet closure (DoF-critical)
|
||||
|
||||
| Setting | Residual r2 | Typical use |
|
||||
|---------|-------------|-------------|
|
||||
| Default (`quality_control: false`) | `h_out − h_g(P)` saturated vapor | Compressor suction after disengagement |
|
||||
| `quality_control: true` | `x_out − target_quality` | Legacy recirculation / two-phase outlet |
|
||||
|
||||
Both keep **the same** `n_equations` (quality replaces sat-vapor; it does not add an extra free residual by itself).
|
||||
`quality_control: true` on a closed cycle often needs a **free actuator** elsewhere or the DoF gate rejects the graph.
|
||||
|
||||
### Residuals & `n_equations()`
|
||||
|
||||
| Row | Equation |
|
||||
|-----|----------|
|
||||
| r0 | `P_out − P_in` (no refrigerant ΔP by default) |
|
||||
| r1 | `ṁ_ref · (h_out − h_in) − Q_eff` |
|
||||
| r2 | sat-vapor **or** quality target (see above) |
|
||||
| r_sec mass | `ṁ_sec,out − ṁ_sec,in` only if live edges and **not** same-branch |
|
||||
| r_sec energy | live secondary energy + duty (blended at low ṁ) |
|
||||
|
||||
```
|
||||
n_equations = 3 + n_secondary
|
||||
n_secondary = 0 # rating (no live edges)
|
||||
| 1 # live edges, same-branch ṁ
|
||||
| 2 # live edges, independent ṁ in/out
|
||||
```
|
||||
|
||||
### Ports
|
||||
|
||||
| Port | Index | Role |
|
||||
|------|-------|------|
|
||||
| `inlet` | 0 | Refrigerant from EXV |
|
||||
| `outlet` | 1 | Refrigerant to compressor suction |
|
||||
| `secondary_inlet` | 2 | Water/brine in |
|
||||
| `secondary_outlet` | 3 | Water/brine out |
|
||||
|
||||
CLI aliases: `water_in` / `brine_in` → secondary_inlet, etc. (`resolve_port_index`).
|
||||
|
||||
### Calibration
|
||||
|
||||
| Factor | Effect | Default |
|
||||
|--------|--------|---------|
|
||||
| `z_ua` (BOLT `Z_UA`) | `UA_eff = z_ua · UA` via inner calib | **1.0** |
|
||||
| `z_dp` | pressure-drop scale if ΔP model used | 1.0 |
|
||||
|
||||
Inverse calibration (CLI `controls[]` / UI Fixed checkboxes): impose a measure (e.g. SST = `saturationTemperature`) and free `z_ua`.
|
||||
|
||||
### JSON parameters
|
||||
|
||||
| Key | Meaning | Unit | Default |
|
||||
|-----|---------|------|---------|
|
||||
| `ua` | UA | W/K | **required** |
|
||||
| `refrigerant` | refrigerant id | – | primary fluid |
|
||||
| `secondary_fluid` | secondary fluid | – | Water / MEG |
|
||||
| `quality_control` | quality residual instead of sat-vapor | bool | `false` |
|
||||
| `target_quality` | x target if quality_control | – | 0.7 |
|
||||
| `secondary_inlet_temp_c` / `_k` | rating T_sec,in | °C / K | – |
|
||||
| `secondary_mass_flow_kg_s` | rating ṁ_sec | kg/s | – |
|
||||
| `secondary_cp_j_per_kgk` | rating cp | J/(kg·K) | 4186 |
|
||||
| `secondary_capacity_rate_w_per_k` | rating C_sec direct | W/K | – |
|
||||
| calib `z_ua` / `z_dp` | Z-factors | – | 1.0 |
|
||||
|
||||
### energy_transfers / mass
|
||||
|
||||
When coupled: cooling `Q ≈ ṁ·(h_out − h_in)` (positive heat absorbed by refrigerant).
|
||||
`port_mass_flows` reports 4-port signs without calling generic inner four-port.
|
||||
|
||||
### Zero flow
|
||||
|
||||
`flow_regularization` on system path: smooth `|ṁ|` for live `C_sec`, activity factors, secondary Δh hold. Seed residuals stay finite if P is non-physical.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But & modèle
|
||||
|
||||
Évaporateur **noyé** tubes-calandre. Duty **ε-NTU** à changement de phase :
|
||||
|
||||
```
|
||||
ε = 1 − exp(−UA / C_sec)
|
||||
Q = ε · C_sec · (T_sec,in − T_evap(P))
|
||||
```
|
||||
|
||||
Pas de corrélation géométrique type Longo/Shah (voir BPHX pour ça). UA est un **paramètre global**, modulable par `z_ua` (défaut **1**).
|
||||
|
||||
### Deux modes (tous deux dans le Newton)
|
||||
|
||||
| Mode | Secondaire | Inconnues eau | Usage |
|
||||
|------|------------|---------------|--------|
|
||||
| **Système** | Ports live Source → HX → Sink | oui | Machine fermée |
|
||||
| **Rating** | Scalaires T + ṁ·cp (ou C_sec) | non | Qualification ; Q ε-NTU **dans** les résidus |
|
||||
|
||||
### Clôture de sortie
|
||||
|
||||
- Défaut : **vapeur saturée** `h_out = h_g(P)` (aspiration compresseur).
|
||||
- `quality_control: true` : `x_out − x_cible` (même nombre d’équations).
|
||||
|
||||
### Résiduels
|
||||
|
||||
`n_equations = 3 + n_secondary` (0 / 1 / 2). Voir tableau EN.
|
||||
|
||||
### Calibration
|
||||
|
||||
Imposer une mesure (SST) et libérer `z_ua` (UI case Fixed, ou `controls[]`).
|
||||
`z_ua = 1` = pas de correction.
|
||||
|
||||
### Ports / JSON
|
||||
|
||||
Identiques aux tableaux EN.
|
||||
48
apps/web/public/docs/components/flow-merger.md
Normal file
48
apps/web/public/docs/components/flow-merger.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# FlowMerger
|
||||
|
||||
Config type: `"FlowMerger"`
|
||||
Source: `crates/components/src/flow_merger.rs`
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
**N inlets → one outlet**. Mass and energy mix at common pressure:
|
||||
|
||||
```
|
||||
ṁ_out = Σ ṁ_in,i
|
||||
ṁ_out · h_out = Σ ṁ_in,i · h_in,i
|
||||
P_out = P_in,i (ideal junction)
|
||||
```
|
||||
|
||||
### Residuals & `n_equations()`
|
||||
|
||||
Mixing mass + energy + pressure equality constraints as implemented.
|
||||
|
||||
### Ports
|
||||
|
||||
`inlet_0` … `inlet_{n-1}`, `outlet`.
|
||||
|
||||
### Calibration
|
||||
|
||||
None by default.
|
||||
|
||||
### JSON
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `n_inlets` | number of inlets | ≥ 2 |
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
**Mélangeur** N → 1. Conservation ṁ et H ; pression commune idéale.
|
||||
|
||||
### JSON
|
||||
|
||||
Voir EN.
|
||||
56
apps/web/public/docs/components/flow-regularization.md
Normal file
56
apps/web/public/docs/components/flow-regularization.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Flow regularization (zero-flow helpers)
|
||||
|
||||
Source: `crates/components/src/heat_exchanger/flow_regularization.rs`
|
||||
Used by: **FloodedEvaporator** (full residual path); **Condenser** / **Evaporator** (smooth `|ṁ|` for live `C_sec`).
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Why
|
||||
|
||||
Zero mass flow is a **valid** state (staging, circuit off, Newton trial steps). Hard branches like `if |m| < ε { Q = 0 }` create **Jacobian discontinuities**.
|
||||
|
||||
### API
|
||||
|
||||
| Function | Meaning |
|
||||
|----------|---------|
|
||||
| `flow_activity(m, ε)` | α = m²/(m²+ε²) ∈ [0,1), α(0)=0 |
|
||||
| `flow_activity_derivative` | dα/dm |
|
||||
| `effective_duty(Q, α_a, α_b)` | Q_eff = α_a · α_b · Q |
|
||||
| `blend_transport_residual` | blend active transport residual with Δh hold |
|
||||
| `blend_transport_partials` | analytic partials of the blend |
|
||||
| `smooth_mass_magnitude` | C¹-ish smooth \|m\| for `C = \|ṁ\| · cp` |
|
||||
| `smooth_mass_magnitude_derivative` | d\|m\|_smooth / dm |
|
||||
|
||||
Defaults: `DEFAULT_M_EPS_KG_S = 1e-4`, `DEFAULT_M_SCALE_KG_S = 0.05`.
|
||||
|
||||
### Interaction with rating mode (Flooded)
|
||||
|
||||
On **FloodedEvaporator system path** (live secondary): duty uses `effective_duty` with α_ref and α_sec.
|
||||
On **Flooded rating path** (scalar C_sec only): residual energy uses **full Q** (no α_ref gate) so `ṁ_ref = 0` is not a trivial root when `C_sec > 0`.
|
||||
|
||||
### DoF rule
|
||||
|
||||
Regularization **must not** change `n_equations()`. It only reshapes residual values and derivatives.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### Pourquoi
|
||||
|
||||
Le débit nul est un état **valide**. Les `if |m| < ε` durs cassent le Newton.
|
||||
|
||||
### API
|
||||
|
||||
Voir le tableau EN.
|
||||
|
||||
### Rating vs système (Flooded)
|
||||
|
||||
- **Système (ports live)** : duty régularisée α_ref · α_sec · Q.
|
||||
- **Rating (scalaires)** : Q **plein** dans le résidu énergie (pas de racine triviale ṁ=0).
|
||||
|
||||
### Règle DoF
|
||||
|
||||
La régularisation **ne change pas** `n_equations()`.
|
||||
51
apps/web/public/docs/components/flow-splitter.md
Normal file
51
apps/web/public/docs/components/flow-splitter.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# FlowSplitter
|
||||
|
||||
Config type: `"FlowSplitter"`
|
||||
Source: `crates/components/src/flow_splitter.rs` (or equivalent)
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
One inlet → **N outlets**. Mass splits across outlet legs; pressure continuous at the node (common header assumption unless specialized ΔP models exist).
|
||||
|
||||
```
|
||||
ṁ_in = Σ ṁ_out,i
|
||||
P_out,i = P_in (ideal splitter)
|
||||
h_out,i = h_in (same enthalpy)
|
||||
```
|
||||
|
||||
### Residuals & `n_equations()`
|
||||
|
||||
Mass split + equal-P / equal-h constraints per topology. Port count depends on `n_outlets` configuration.
|
||||
|
||||
### Ports
|
||||
|
||||
| Port | Role |
|
||||
|------|------|
|
||||
| `inlet` | single inlet |
|
||||
| `outlet_0` … `outlet_{n-1}` | outlets |
|
||||
|
||||
### Calibration
|
||||
|
||||
None by default.
|
||||
|
||||
### JSON
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `n_outlets` | number of legs | ≥ 2 |
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
**Séparateur de débit** 1 → N. Conservation de ṁ ; même P/h idéalement.
|
||||
|
||||
### Ports / JSON
|
||||
|
||||
Voir EN.
|
||||
44
apps/web/public/docs/components/free-cooling-exchanger.md
Normal file
44
apps/web/public/docs/components/free-cooling-exchanger.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# FreeCoolingExchanger
|
||||
|
||||
Config types: `"FreeCoolingExchanger"`, `"FreeCooling"`
|
||||
Source: free-cooling HX module
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Free-cooling heat exchanger between two liquid loops (e.g. tower water ↔ chilled water) without vapor-compression. Effectiveness–NTU or UA·LMTD between two single-phase streams.
|
||||
|
||||
```
|
||||
Q = ε · C_min · (T_hot,in − T_cold,in)
|
||||
```
|
||||
|
||||
### Residuals
|
||||
|
||||
Two-stream energy balances + optional ΔP per leg.
|
||||
|
||||
### Ports
|
||||
|
||||
Hot and cold in/out (4-port).
|
||||
|
||||
### Calibration
|
||||
|
||||
`z_ua` default **1.0**.
|
||||
|
||||
### JSON
|
||||
|
||||
UA, fluids, optional secondary stream params — see CLI arm.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
Échangeur de **free-cooling** (liquide–liquide), sans cycle frigo.
|
||||
|
||||
### Calibration
|
||||
|
||||
Z_UA = 1 par défaut.
|
||||
60
apps/web/public/docs/components/heat-exchanger-generic.md
Normal file
60
apps/web/public/docs/components/heat-exchanger-generic.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# HeatExchanger (generic)
|
||||
|
||||
Config type: `"HeatExchanger"`
|
||||
Source: `crates/components/src/heat_exchanger/exchanger.rs` + ε-NTU / LMTD models
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Generic two-stream HX with selectable model:
|
||||
|
||||
- **ε-NTU** effectiveness
|
||||
- **LMTD** / counterflow forms
|
||||
|
||||
Ports: hot_in/out, cold_in/out (Modelica-style 4-port).
|
||||
|
||||
```
|
||||
NTU = UA / C_min
|
||||
ε = f(NTU, C_r, flow arrangement)
|
||||
Q = ε · C_min · (T_hot,in − T_cold,in)
|
||||
```
|
||||
|
||||
**Requires live four-port edge state** for residual evaluation on the generic path — inlet-only scalar BCs do not invent outlet states.
|
||||
|
||||
### Residuals & `n_equations()`
|
||||
|
||||
Inner model residual count (often 2–3 per side balance depending on configuration).
|
||||
|
||||
### Ports
|
||||
|
||||
| Port | Role |
|
||||
|------|------|
|
||||
| `hot_inlet` / `hot_outlet` | hot stream |
|
||||
| `cold_inlet` / `cold_outlet` | cold stream |
|
||||
|
||||
### Calibration
|
||||
|
||||
`z_ua` on UA (default 1.0).
|
||||
|
||||
### When not to use
|
||||
|
||||
For refrigeration condensers/evaporators prefer specialized `Condenser` / `Evaporator` / `FloodedEvaporator` / BPHX which know phase-change ε-NTU and secondary dual modes.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
Échangeur **générique** 4 ports (ε-NTU / LMTD).
|
||||
|
||||
### Attention
|
||||
|
||||
Exige un état **4 ports live**. Pour frigo, préférer Condenser / Evaporator / Flooded / BPHX.
|
||||
|
||||
### Calibration
|
||||
|
||||
Z_UA = 1 par défaut.
|
||||
150
apps/web/public/docs/components/isenthalpic-expansion-valve.md
Normal file
150
apps/web/public/docs/components/isenthalpic-expansion-valve.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# IsenthalpicExpansionValve (EXV)
|
||||
|
||||
Config types: `"IsenthalpicExpansionValve"`, `"EXV"`
|
||||
Source: `crates/components/src/isenthalpic_expansion_valve.rs`
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & physical model
|
||||
|
||||
Isenthalpic expansion (throttling) valve for vapor-compression cycles. Three model families:
|
||||
|
||||
| Family | Trigger | Physics |
|
||||
|--------|---------|---------|
|
||||
| **A — Fixed pressure** | default | Pins `P_out = P_sat(T_evap)` + isenthalpy |
|
||||
| **B — Emergent pressure** | `emergent_pressure: true` | Isenthalpy only; low-side P from evaporator |
|
||||
| **C — Orifice** | `orifice_kv` set | Emergent + physical flow law; **opening is a free DoF** |
|
||||
|
||||
Orifice law (arch-6 physical actuator):
|
||||
|
||||
```
|
||||
ṁ = Kv · opening · √(2 · ρ_in · max(P_in − P_out, 0)) , opening ∈ [0, 1]
|
||||
```
|
||||
|
||||
`with_orifice(kv)` / JSON `orifice_kv` forces `emergent_pressure = true`.
|
||||
|
||||
### Residuals & `n_equations()`
|
||||
|
||||
| Mode | same-branch | orifice | n_equations | Residuals |
|
||||
|------|-------------|---------|-------------|-----------|
|
||||
| Fixed P | no | no | 3 | r0 `P_out − P_sat(T_evap)`; r1 `h_out − h_in`; r2 `ṁ_out − ṁ_in` |
|
||||
| Fixed P | yes | no | 2 | r0, r1 |
|
||||
| Emergent | no | no | 2 | r0 `h_out − h_in`; r1 `ṁ_out − ṁ_in` |
|
||||
| Emergent | yes | no | 1 | r0 `h_out − h_in` |
|
||||
| Emergent + orifice | either | yes | +1 | + `ṁ − Kv·opening·√(2·ρ_in·ΔP)` |
|
||||
|
||||
Orifice adds **1 equation** and the opening adds **1 unknown** → DoF stays balanced. Pair with a `superheat_regulated` evaporator (drops its SH residual) and a controller on `opening` for regulated superheat.
|
||||
|
||||
### Ports
|
||||
|
||||
| Edge | Role |
|
||||
|------|------|
|
||||
| 0 | inlet (cond → EXV) |
|
||||
| 1 | outlet (EXV → evap) |
|
||||
|
||||
### Emergent pressure
|
||||
|
||||
Enabled by `emergent_pressure: true` or automatically by orifice mode. Removes the `P_out = P_sat(T_evap)` pin.
|
||||
|
||||
### Calibration / actuators
|
||||
|
||||
| Item | Notes |
|
||||
|------|-------|
|
||||
| Control factor `"opening"` | maps to `actuator` slot; requires `orifice_kv` |
|
||||
| Free actuator `{name}__opening` | registered when orifice configured without a loop |
|
||||
| `z_flow` / `z_dp` | **not** used on this component |
|
||||
|
||||
### measure_output / energy_transfers
|
||||
|
||||
Not specialized (`energy_transfers` none / adiabatic throttling: Q = W = 0).
|
||||
|
||||
### JSON parameters
|
||||
|
||||
| Key | Meaning | Unit | Default |
|
||||
|-----|---------|------|---------|
|
||||
| `t_evap_k` | target evaporating T for P_sat | K | 275.15 |
|
||||
| `fluid` | refrigerant | – | primary |
|
||||
| `emergent_pressure` | drop P_evap pin | bool | false |
|
||||
| `orifice_kv` | orifice coefficient Kv | m² | – (none ⇒ no orifice) |
|
||||
| `orifice_opening_init` | initial opening | – | 0.5 |
|
||||
| `orifice_opening_min` | min bound | – | 0.02 |
|
||||
| `orifice_opening_max` | max bound | – | 1.0 |
|
||||
|
||||
### Notes
|
||||
|
||||
Preferred EXV for modern cycle configs. For the older port-object valve with On/Off/Bypass see [expansion-valve.md](./expansion-valve.md).
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But & modèle physique
|
||||
|
||||
Détendeur isenthalpique (laminage) pour cycles à compression de vapeur. Trois familles :
|
||||
|
||||
| Famille | Déclencheur | Physique |
|
||||
|---------|-------------|----------|
|
||||
| **A — Pression fixée** | défaut | Impose `P_out = P_sat(T_evap)` + isenthalpie |
|
||||
| **B — Pression émergente** | `emergent_pressure: true` | Isenthalpie seule ; P BP par l'évaporateur |
|
||||
| **C — Orifice** | `orifice_kv` | Émergent + loi de débit ; **ouverture = DoF libre** |
|
||||
|
||||
Loi d'orifice :
|
||||
|
||||
```
|
||||
ṁ = Kv · opening · √(2 · ρ_in · max(P_in − P_out, 0)) , opening ∈ [0, 1]
|
||||
```
|
||||
|
||||
`orifice_kv` force `emergent_pressure = true`.
|
||||
|
||||
### Résiduels & `n_equations()`
|
||||
|
||||
| Mode | même branche | orifice | n_equations | Résidus |
|
||||
|------|--------------|---------|-------------|---------|
|
||||
| P fixe | non | non | 3 | r0 `P_out − P_sat(T_evap)` ; r1 `h_out − h_in` ; r2 `ṁ_out − ṁ_in` |
|
||||
| P fixe | oui | non | 2 | r0, r1 |
|
||||
| Émergent | non | non | 2 | r0 `h_out − h_in` ; r1 `ṁ_out − ṁ_in` |
|
||||
| Émergent | oui | non | 1 | r0 `h_out − h_in` |
|
||||
| Émergent + orifice | – | oui | +1 | + `ṁ − Kv·opening·√(2·ρ_in·ΔP)` |
|
||||
|
||||
L'orifice ajoute **1 équation** et l'ouverture **1 inconnu** → DoF équilibré. Couplé à un évaporateur `superheat_regulated` et un contrôleur sur `opening`, la surchauffe devient régulée.
|
||||
|
||||
### Ports
|
||||
|
||||
| Arête | Rôle |
|
||||
|-------|------|
|
||||
| 0 | entrée (cond → EXV) |
|
||||
| 1 | sortie (EXV → évap) |
|
||||
|
||||
### Pression émergente
|
||||
|
||||
Via `emergent_pressure: true` ou automatiquement en mode orifice. Supprime le pin `P_out = P_sat(T_evap)`.
|
||||
|
||||
### Calibration / actionneurs
|
||||
|
||||
| Élément | Notes |
|
||||
|---------|-------|
|
||||
| Facteur `"opening"` | mappe le slot `actuator` ; nécessite `orifice_kv` |
|
||||
| Actionneur libre `{name}__opening` | si orifice sans boucle |
|
||||
| `z_flow` / `z_dp` | **non** utilisés |
|
||||
|
||||
### measure_output / energy_transfers
|
||||
|
||||
Non spécialisés ; laminage adiabatique (Q = W = 0).
|
||||
|
||||
### Paramètres JSON
|
||||
|
||||
| Clé | Signification | Unité | Défaut |
|
||||
|-----|---------------|-------|--------|
|
||||
| `t_evap_k` | T évaporation cible pour P_sat | K | 275.15 |
|
||||
| `fluid` | frigorigène | – | primaire |
|
||||
| `emergent_pressure` | supprime le pin P_evap | bool | false |
|
||||
| `orifice_kv` | coefficient d'orifice Kv | m² | – (aucun ⇒ pas d'orifice) |
|
||||
| `orifice_opening_init` | ouverture initiale | – | 0.5 |
|
||||
| `orifice_opening_min` | borne min | – | 0.02 |
|
||||
| `orifice_opening_max` | borne max | – | 1.0 |
|
||||
|
||||
### Notes
|
||||
|
||||
EXV préféré pour les configs de cycle modernes. Ancienne vanne port-object → [expansion-valve.md](./expansion-valve.md).
|
||||
214
apps/web/public/docs/components/isentropic-compressor.md
Normal file
214
apps/web/public/docs/components/isentropic-compressor.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# IsentropicCompressor
|
||||
|
||||
Config type: `"IsentropicCompressor"`
|
||||
Source: `crates/components/src/isentropic_compressor.rs`
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & physical model
|
||||
|
||||
Vapor-compression compressor for cycle simulation. Two operating families:
|
||||
|
||||
| Mode | When | Mass / pressure behaviour |
|
||||
|------|------|---------------------------|
|
||||
| **Fixed-pressure** (default) | `emergent_pressure: false` | Pins `P_dis = P_sat(T_cond)`; mass continuity across suction/discharge |
|
||||
| **Emergent-pressure** | `emergent_pressure: true` | Closes ṁ with a **volumetric displacement law**; `P_dis` floats from the condenser ↔ secondary balance |
|
||||
|
||||
True isentropic path via CoolProp: `(P,h)→s` then `(P,s)→h_is`, corrected by isentropic efficiency:
|
||||
|
||||
```
|
||||
h_dis = h_suc + (h_is − h_suc) / η_is,eff
|
||||
```
|
||||
|
||||
Swept mass flow (emergent only):
|
||||
|
||||
```
|
||||
ṁ_calc = ρ_suc · V_s · N · η_vol(P_dis/P_suc) · f_VSD,vol
|
||||
ṁ = σ · z_flow · ṁ_calc
|
||||
```
|
||||
|
||||
Volumetric efficiency models:
|
||||
|
||||
| Model | Formula |
|
||||
|-------|---------|
|
||||
| Constant | `η_vol = const` (default 1.0) |
|
||||
| Clearance | `η_vol = 1 + C − C · (P_dis/P_suc)^(1/n)` |
|
||||
|
||||
Optional **VSD speed map** (quadratic, identity default `[1,0,0]`):
|
||||
|
||||
```
|
||||
f(r) = c0 + c1·r + c2·r² , r = N / N_ref , clamped ∈ [0.1, 1.2]
|
||||
η_vol,eff = η_vol · f_vol(r) ; η_is,eff = η_is · f_is(r)
|
||||
```
|
||||
|
||||
Optional **liquid injection** desuperheat (no extra equation; φ from controls):
|
||||
|
||||
```
|
||||
h_dis,eff = h_dis − φ_inj · (h_dis − h_f(P_dis)) , φ_inj ∈ [0, φ_max]
|
||||
```
|
||||
|
||||
Design anchors `t_cond_k`, `t_evap_k`, `superheat_k` are used for fixed-pressure pins and as initial-condition helpers; in emergent mode the live suction `(P,h)` drives the isentropic path.
|
||||
|
||||
### Residuals & `n_equations()`
|
||||
|
||||
```
|
||||
n_equations = (2 if same_branch else 3) + (1 if slide_valve active else 0)
|
||||
```
|
||||
|
||||
| Row | Fixed-pressure | Emergent-pressure |
|
||||
|-----|----------------|-------------------|
|
||||
| r0 | `P_dis − P_sat(T_cond)` | `ṁ − σ·z_flow·ṁ_calc` |
|
||||
| r1 | `H_dis − h_dis` | `H_dis − h_dis,eff` |
|
||||
| r2 | `ṁ_dis − ṁ_suc` (dropped if same-branch) | same |
|
||||
| r3 | — | (slide) `T_sat(P_suc) − SST_target` |
|
||||
|
||||
### Ports
|
||||
|
||||
| Index | Role |
|
||||
|-------|------|
|
||||
| 0 | suction (inlet) |
|
||||
| 1 | discharge (outlet) |
|
||||
|
||||
Edge-wired via `set_system_context` (CM1.3 ṁ/P/h triples). `get_ports()` may be empty.
|
||||
|
||||
### Emergent pressure & actuators
|
||||
|
||||
- Requires `displacement_m3` and `speed_hz` when `emergent_pressure: true`.
|
||||
- **Slide valve** (`slide_valve_sst_target_k` / `_c`): free actuator σ ∈ [σ_min, 1] scales swept volume and holds SST.
|
||||
- **Liquid injection** (`liquid_injection: true`): φ_inj on the `actuator` / control factor `"injection"`; closing equation from a user `controls[]` loop (e.g. max DGT), not hard-coded.
|
||||
|
||||
### Calibration
|
||||
|
||||
| Factor | Effect | Default |
|
||||
|--------|--------|---------|
|
||||
| `z_flow` | scales swept ṁ (emergent r0) | **1.0** |
|
||||
| `actuator` | slide σ **or** injection φ | – |
|
||||
|
||||
### measure_output / energy_transfers
|
||||
|
||||
- `measure_output(Temperature)` → discharge gas temperature (DGT) for injection control.
|
||||
- `energy_transfers`: `(Q, W) = (0, −ṁ·(h_dis,work − h_suc))` — adiabatic; shaft work negative. With liquid injection, work uses un-desuperheated compression enthalpy.
|
||||
|
||||
### JSON parameters
|
||||
|
||||
| Key | Meaning | Unit | Default |
|
||||
|-----|---------|------|---------|
|
||||
| `isentropic_efficiency` | η_is | – | 0.75 |
|
||||
| `t_cond_k` | condensing sat. T (fixed pin / design) | K | 323.15 |
|
||||
| `t_evap_k` | evaporating sat. T (design) | K | 275.15 |
|
||||
| `superheat_k` | suction superheat design | K | 5.0 |
|
||||
| `fluid` | refrigerant | – | primary |
|
||||
| `emergent_pressure` | enable displacement closure | bool | false |
|
||||
| `displacement_m3` | swept volume V_s | m³/rev | 0.0 |
|
||||
| `speed_hz` | rotational speed N | rev/s | 0.0 |
|
||||
| `volumetric_efficiency` | constant η_vol | – | 1.0 |
|
||||
| `clearance` | clearance ratio C (enables clearance model) | – | – |
|
||||
| `polytropic_n` | re-expansion exponent | – | 1.1 |
|
||||
| `vsd_reference_speed_hz` | VSD N_ref (enables map) | rev/s | – |
|
||||
| `vsd_volumetric_coeffs` | `[c0,c1,c2]` η_vol map | – | [1,0,0] |
|
||||
| `vsd_isentropic_coeffs` | `[c0,c1,c2]` η_is map | – | [1,0,0] |
|
||||
| `slide_valve_sst_target_k` / `_c` | slide SST setpoint | K / °C | – |
|
||||
| `liquid_injection` | enable injection desuperheat | bool | false |
|
||||
| `slide_position_init` / `min` / `max` | free-actuator bounds | – | 1.0 / 0.1 / 1.0 |
|
||||
|
||||
### Notes
|
||||
|
||||
Preferred cycle compressor for physics-based machines. For manufacturer AHRI maps use `"Compressor"`; for economized screws use `"ScrewEconomizerCompressor"`.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But & modèle physique
|
||||
|
||||
Compresseur à compression de vapeur. Deux familles de fonctionnement :
|
||||
|
||||
| Mode | Quand | Comportement |
|
||||
|------|-------|--------------|
|
||||
| **Pression fixée** (défaut) | `emergent_pressure: false` | Impose `P_dis = P_sat(T_cond)` ; continuité de masse |
|
||||
| **Pression émergente** | `emergent_pressure: true` | Ferme ṁ par une **loi volumétrique** ; `P_dis` flotte via le condenseur |
|
||||
|
||||
Chemin isentropique CoolProp + rendement :
|
||||
|
||||
```
|
||||
h_dis = h_suc + (h_is − h_suc) / η_is,eff
|
||||
```
|
||||
|
||||
Débit balayé (émergent) :
|
||||
|
||||
```
|
||||
ṁ_calc = ρ_suc · V_s · N · η_vol(P_dis/P_suc) · f_VSD,vol
|
||||
ṁ = σ · z_flow · ṁ_calc
|
||||
```
|
||||
|
||||
Modèles de rendement volumétrique : constant, ou volume mort `η_vol = 1 + C − C·Pr^(1/n)`.
|
||||
Carte VSD optionnelle (quadratique, identité `[1,0,0]`).
|
||||
Injection liquide optionnelle : `h_dis,eff = h_dis − φ_inj·(h_dis − h_f(P_dis))` (pas d'équation interne).
|
||||
|
||||
### Résiduels & `n_equations()`
|
||||
|
||||
```
|
||||
n_equations = (2 si même branche sinon 3) + (1 si tiroir actif)
|
||||
```
|
||||
|
||||
| Ligne | Pression fixée | Pression émergente |
|
||||
|-------|----------------|--------------------|
|
||||
| r0 | `P_dis − P_sat(T_cond)` | `ṁ − σ·z_flow·ṁ_calc` |
|
||||
| r1 | `H_dis − h_dis` | `H_dis − h_dis,eff` |
|
||||
| r2 | `ṁ_dis − ṁ_suc` (supprimée si même branche) | idem |
|
||||
| r3 | — | (tiroir) `T_sat(P_suc) − SST_cible` |
|
||||
|
||||
### Ports
|
||||
|
||||
| Index | Rôle |
|
||||
|-------|------|
|
||||
| 0 | aspiration (entrée) |
|
||||
| 1 | refoulement (sortie) |
|
||||
|
||||
Câblage par arêtes (`set_system_context`, triples ṁ/P/h CM1.3).
|
||||
|
||||
### Pression émergente & actionneurs
|
||||
|
||||
- `displacement_m3` et `speed_hz` obligatoires en mode émergent.
|
||||
- **Tiroir** (`slide_valve_sst_target_k` / `_c`) : actionneur libre σ pour tenir la SST.
|
||||
- **Injection liquide** : φ_inj via boucle `controls[]` (ex. DGT max), facteur `"injection"`.
|
||||
|
||||
### Calibration
|
||||
|
||||
| Facteur | Effet | Défaut |
|
||||
|---------|-------|--------|
|
||||
| `z_flow` | échelle le débit balayé | **1.0** |
|
||||
| `actuator` | position tiroir σ **ou** ratio d'injection φ | – |
|
||||
|
||||
### measure_output / energy_transfers
|
||||
|
||||
- `Temperature` → température des gaz de refoulement (DGT).
|
||||
- `(Q, W) = (0, −ṁ·(h_dis,work − h_suc))` — adiabatique ; travail sur le compresseur négatif.
|
||||
|
||||
### Paramètres JSON
|
||||
|
||||
| Clé | Signification | Unité | Défaut |
|
||||
|-----|---------------|-------|--------|
|
||||
| `isentropic_efficiency` | η_is | – | 0.75 |
|
||||
| `t_cond_k` | T sat. condensation (pin / design) | K | 323.15 |
|
||||
| `t_evap_k` | T sat. évaporation (design) | K | 275.15 |
|
||||
| `superheat_k` | surchauffe aspiration design | K | 5.0 |
|
||||
| `fluid` | fluide frigorigène | – | primaire |
|
||||
| `emergent_pressure` | active la fermeture volumétrique | bool | false |
|
||||
| `displacement_m3` | cylindrée V_s | m³/tr | 0.0 |
|
||||
| `speed_hz` | vitesse N | tr/s | 0.0 |
|
||||
| `volumetric_efficiency` | η_vol constant | – | 1.0 |
|
||||
| `clearance` | rapport volume mort C | – | – |
|
||||
| `polytropic_n` | exposant de détente | – | 1.1 |
|
||||
| `vsd_reference_speed_hz` | N_ref carte VSD | tr/s | – |
|
||||
| `vsd_volumetric_coeffs` | `[c0,c1,c2]` carte η_vol | – | [1,0,0] |
|
||||
| `vsd_isentropic_coeffs` | `[c0,c1,c2]` carte η_is | – | [1,0,0] |
|
||||
| `slide_valve_sst_target_k` / `_c` | consigne SST tiroir | K / °C | – |
|
||||
| `liquid_injection` | active la désurchauffe par injection | bool | false |
|
||||
| `slide_position_init` / `min` / `max` | bornes actionneur libre | – | 1.0 / 0.1 / 1.0 |
|
||||
|
||||
### Notes
|
||||
|
||||
Compresseur de cycle préféré pour les machines physiques. Cartes fabricant AHRI → `"Compressor"` ; vis économisée → `"ScrewEconomizerCompressor"`.
|
||||
40
apps/web/public/docs/components/mchx-condenser-coil.md
Normal file
40
apps/web/public/docs/components/mchx-condenser-coil.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# MchxCondenserCoil / MchxCoil
|
||||
|
||||
Config types: `"MchxCondenserCoil"`, `"MchxCoil"`
|
||||
Source: microchannel condenser coil module
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
**Microchannel** air-cooled condenser coil. Compact multi-port tubes + air fins. UA from geometry and air/refrigerant side coefficients as coded; runtime residual path follows condenser-style energy balances.
|
||||
|
||||
### Residuals
|
||||
|
||||
Condenser-like refrigerant + air coupling residuals.
|
||||
|
||||
### Ports
|
||||
|
||||
Refrigerant + air.
|
||||
|
||||
### Calibration
|
||||
|
||||
Z-factors on UA/ΔP when exposed (default 1.0).
|
||||
|
||||
### JSON
|
||||
|
||||
Geometry and air-side setpoints per CLI arm / UI meta (`design_capacity_kw`, face velocity, OAT, …).
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
Batterie **micro-canaux** de condensation.
|
||||
|
||||
### JSON
|
||||
|
||||
Voir meta UI / exemples.
|
||||
35
apps/web/public/docs/components/moving-boundary-hx.md
Normal file
35
apps/web/public/docs/components/moving-boundary-hx.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# MovingBoundaryHX
|
||||
|
||||
Source: moving-boundary / multi-zone HX identification helpers
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Research / identification path: multi-zone (SH / TP / SC) UA allocation feeding an ε-NTU or zone energy balance. **Not** the default production Condenser/Evaporator path.
|
||||
|
||||
### Residuals
|
||||
|
||||
Zone energy balances + interface quality/enthalpy consistency when fully enabled. Coverage may be partial — check source and tests before relying in production machines.
|
||||
|
||||
### Correlations
|
||||
|
||||
Zone UA may come from geometry or identified parameters rather than a single Longo map.
|
||||
|
||||
### Recommendation
|
||||
|
||||
Production chillers: use **Condenser**, **Evaporator**, **FloodedEvaporator**, or **BPHX** with documented dual-mode secondary and DoF discipline.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
HX **moving-boundary** multi-zones (recherche / identification).
|
||||
|
||||
### Recommandation
|
||||
|
||||
En production : Condenser / Evaporator / Flooded / BPHX.
|
||||
54
apps/web/public/docs/components/pipe.md
Normal file
54
apps/web/public/docs/components/pipe.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Pipe
|
||||
|
||||
Config type: `"Pipe"`
|
||||
Source: `crates/components/src/pipe.rs`
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Fluid duct with **friction pressure drop** (Darcy/Colebrook-style or equivalent implementation) and near-isenthalpic or adiabatic energy transport:
|
||||
|
||||
```
|
||||
ΔP = f(L, D, ε, Re, ṁ, ρ)
|
||||
h_out ≈ h_in (or with small heat loss if modeled)
|
||||
```
|
||||
|
||||
### Residuals & `n_equations()`
|
||||
|
||||
Pressure-drop residual + energy residual (typically **2** on a series branch).
|
||||
|
||||
### Ports
|
||||
|
||||
`inlet` / `outlet`.
|
||||
|
||||
### Calibration
|
||||
|
||||
`z_dp` (or equivalent) scales ΔP when exposed via calib — default **1.0**.
|
||||
|
||||
### JSON (main)
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `length_m` | length | required |
|
||||
| `diameter_m` | inner diameter | required |
|
||||
| `roughness_m` | roughness | small metal default |
|
||||
| fluid | water / refrigerant path | from circuit |
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But & modèle
|
||||
|
||||
**Conduite** avec pertes de charge et transport d’enthalpie.
|
||||
|
||||
### Calibration
|
||||
|
||||
Facteur de perte de charge (défaut 1).
|
||||
|
||||
### JSON
|
||||
|
||||
Voir EN.
|
||||
56
apps/web/public/docs/components/pump.md
Normal file
56
apps/web/public/docs/components/pump.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Pump
|
||||
|
||||
Config type: `"Pump"`
|
||||
Source: `crates/components/src/pump.rs`
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
Liquid pump with **head/power curves** vs volume flow and speed. Typestate connect pattern like Fan.
|
||||
|
||||
```
|
||||
ΔP = ρ · g · H(Q, N)
|
||||
Ẇ = f_power(Q, N)
|
||||
```
|
||||
|
||||
### Residuals & `n_equations()`
|
||||
|
||||
Head residual + energy/power residual as implemented; see source `n_equations()`.
|
||||
|
||||
### Ports
|
||||
|
||||
`inlet` / `outlet` on liquid (brine/water) branch.
|
||||
|
||||
### DoF warning
|
||||
|
||||
Do **not** impose `m_flow_kg_s` on a BrineSource **and** a pump curve on the same branch without freeing one — over-constrained loop.
|
||||
|
||||
### Calibration
|
||||
|
||||
Curve scale factors when exposed (default 1.0).
|
||||
|
||||
### JSON (main)
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| curves / preset | H–Q map | required |
|
||||
| speed | operating speed | – |
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But & modèle
|
||||
|
||||
**Pompe** liquide sur courbes H–Q.
|
||||
|
||||
### Attention DoF
|
||||
|
||||
Ne pas imposer ṁ Source **et** courbe pompe sur la même branche.
|
||||
|
||||
### Ports / JSON
|
||||
|
||||
Voir EN.
|
||||
44
apps/web/public/docs/components/reversing-valve.md
Normal file
44
apps/web/public/docs/components/reversing-valve.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# ReversingValve / FourWayValve
|
||||
|
||||
Config types: `"ReversingValve"`, `"FourWayValve"`
|
||||
Source: reversing valve module
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose & model
|
||||
|
||||
4-way reversing valve for heat-pump mode swap (heating ↔ cooling). Routes compressor discharge/suction between indoor and outdoor exchangers according to `mode` (or boolean heat/cool).
|
||||
|
||||
Ideal model: port permutation with negligible ΔP/Δh; real models may add leakage or pressure drop.
|
||||
|
||||
### Residuals
|
||||
|
||||
Port coupling residuals matching the selected flow graph for the active mode.
|
||||
|
||||
### Ports
|
||||
|
||||
Four refrigerant ports (naming depends on implementation: e.g. compressor discharge/suction, indoor, outdoor).
|
||||
|
||||
### JSON (main)
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `mode` / `reversing_mode` | heat / cool | – |
|
||||
|
||||
### Calibration
|
||||
|
||||
Usually none; treat as topology switch.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
**Vanne 4 voies** pour inverser le cycle PAC.
|
||||
|
||||
### JSON
|
||||
|
||||
Mode chaud / froid. Voir EN.
|
||||
@@ -0,0 +1,93 @@
|
||||
# ScrewEconomizerCompressor / ScrewCompressor
|
||||
|
||||
Config types: `"ScrewEconomizerCompressor"`, `"ScrewCompressor"`
|
||||
Source: `crates/components/src/screw_economizer_compressor.rs`
|
||||
Polynomials: `Polynomial2D` bilinear SST/SDT
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Purpose
|
||||
|
||||
Twin-screw compressor with **economizer injection** port. Manufacturer performance as **bi-quadratic (bilinear) maps** of SST and SDT.
|
||||
|
||||
### Performance maps
|
||||
|
||||
```
|
||||
ṁ_suction = z_flow · (a00 + a10·SST + a01·SDT + a11·SST·SDT)
|
||||
Ẇ_shaft = z_power · (b00 + b10·SST + b01·SDT + b11·SST·SDT)
|
||||
ṁ_eco ≈ eco_fraction · ṁ_suction (or eco poly)
|
||||
```
|
||||
|
||||
JSON coefficient names (CLI):
|
||||
|
||||
| Mass flow | Power |
|
||||
|-----------|-------|
|
||||
| `mf_a00`, `mf_a10`, `mf_a01`, `mf_a11` | `pw_b00`, `pw_b10`, `pw_b01`, `pw_b11` |
|
||||
|
||||
### Built-in presets
|
||||
|
||||
| `preset` | Meaning |
|
||||
|----------|---------|
|
||||
| `bitzer_generic_200kw` | Bitzer-like ~200 kW R134a map |
|
||||
| `grasso_generic_200kw` | Grasso-like ~200 kW map |
|
||||
| (empty) | generic defaults |
|
||||
|
||||
Explicit `mf_*` / `pw_*` **override** preset values.
|
||||
|
||||
### Ports
|
||||
|
||||
| Port | Role |
|
||||
|------|------|
|
||||
| `suction` / `inlet` | main suction |
|
||||
| `discharge` / `outlet` | discharge |
|
||||
| `economizer` / `eco` | intermediate injection |
|
||||
|
||||
### Other parameters
|
||||
|
||||
| Key | Meaning | Default |
|
||||
|-----|---------|---------|
|
||||
| `frequency_hz` | drive frequency | 50 |
|
||||
| `nominal_frequency_hz` | rated f | 50 |
|
||||
| `mechanical_efficiency` | η_mech | 0.92 |
|
||||
| `economizer_fraction` | eco flow share | from preset |
|
||||
|
||||
### Calibration Z
|
||||
|
||||
| Factor | Default |
|
||||
|--------|---------|
|
||||
| `z_flow` | **1.0** |
|
||||
| `z_flow_eco` | **1.0** |
|
||||
| `z_power` | **1.0** |
|
||||
| `z_etav` | 1.0 |
|
||||
|
||||
### UI
|
||||
|
||||
- Tab **General**: frequency, efficiency, preset
|
||||
- Tab **Map (polynomials)**: mf_a** / pw_b** with defaults filled from preset
|
||||
- Help documents the bilinear formula
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### But
|
||||
|
||||
Compresseur **à vis** 3 ports + injection économiseur.
|
||||
|
||||
### Carte
|
||||
|
||||
Polynôme **bilinéaire** SST/SDT pour ṁ et puissance. Presets Bitzer / Grasso.
|
||||
|
||||
### Coeffs JSON
|
||||
|
||||
`mf_a00…a11` (débit), `pw_b00…b11` (puissance).
|
||||
|
||||
### Calibration
|
||||
|
||||
Z = **1** par défaut.
|
||||
|
||||
### Ports
|
||||
|
||||
Aspiration, refoulement, économiseur.
|
||||
157
apps/web/public/docs/components/thermal-load.md
Normal file
157
apps/web/public/docs/components/thermal-load.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# ThermalLoad
|
||||
|
||||
> Cold-side receiver of a physical inter-circuit thermal coupling.
|
||||
> Récepteur côté froid d'un couplage thermique physique inter-circuits.
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Physical model
|
||||
|
||||
`ThermalLoad` models a hydronic load segment — e.g. the cooling-water side of
|
||||
a shared heat exchanger — that receives an **externally-determined heat rate
|
||||
Q [W]** from the solver's thermal-coupling layer.
|
||||
|
||||
It follows the BOLT/Modelica boundary pattern
|
||||
(`BOLT.BoundaryNode.Coolant.Source → HX → Sink`): the loop's pressure and
|
||||
inlet temperature are fixed by **boundary components**, not by the load:
|
||||
|
||||
```text
|
||||
BrineSource(P_set, T_in) ──edge──▶ ThermalLoad ──edge──▶ BrineSink(P_back, T free)
|
||||
```
|
||||
|
||||
The outlet temperature is **emergent**: `T_out = T_in + Q / (ṁ·cp)` (the sink
|
||||
temperature must be left free — do not set `t_set_c` on the `BrineSink`, or
|
||||
the loop becomes over-determined).
|
||||
|
||||
### Residual equations — `n_equations() = 2`
|
||||
|
||||
```text
|
||||
r0: ṁ − ṁ_design (imposed design flow)
|
||||
r1: ṁ_design·(h_out − h_in) − Q_ext (energy balance, Q_ext = state[q_idx])
|
||||
```
|
||||
|
||||
The energy balance uses the *design* flow (a constant): r0 already pins
|
||||
`ṁ = ṁ_design`, and the constant form keeps the block linear and structurally
|
||||
nonsingular even when the initializer starts at `ṁ = 0`.
|
||||
|
||||
`Q_ext` is read from the per-coupling state unknown wired by
|
||||
`System::finalize()` via `Component::set_external_heat_index`. Unwired ⇒
|
||||
`Q_ext = 0` (adiabatic pass-through).
|
||||
|
||||
### DoF balance (water loop)
|
||||
|
||||
Unknowns: 1 ṁ (shared branch) + 2×(P,h) + 1 Q = 6.
|
||||
Equations: BrineSource 2 + ThermalLoad 2 + BrineSink 1 (T free) + coupling 1 = 6. ✓
|
||||
|
||||
### Jacobian
|
||||
|
||||
Exact and analytic (the whole block is linear): unit entry on the ṁ row,
|
||||
`±ṁ_design` on r1's enthalpy columns, `−1` on the coupling Q column.
|
||||
|
||||
### Operational states
|
||||
|
||||
| State | r0 | r1 |
|
||||
|---|---|---|
|
||||
| `On` | `ṁ = ṁ_design` | `ṁ_design·Δh = Q` |
|
||||
| `Bypass` | `ṁ = ṁ_design` | `h_out = h_in` (adiabatic) |
|
||||
| `Off` | `ṁ = 0` | `h_out = h_in` |
|
||||
|
||||
### `measure_output`
|
||||
|
||||
| Kind | Value |
|
||||
|---|---|
|
||||
| `Capacity` / `HeatTransferRate` | `abs(Q_ext)` [W] |
|
||||
| `MassFlowRate` | inlet ṁ [kg/s] |
|
||||
|
||||
### `energy_transfers`
|
||||
|
||||
`(Q_ext, 0)` — heat added *to* the component is positive. The component is
|
||||
**excluded from cycle-performance aggregation** (`counts_in_cycle_performance()
|
||||
= false`): the absorbed Q is the primary cycle's rejected duty, not extra
|
||||
cooling capacity. It still participates in per-component First Law validation.
|
||||
|
||||
### JSON parameters (CLI)
|
||||
|
||||
| Parameter | Unit | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `mass_flow_kg_s` | kg/s | `0.5` | Imposed design mass flow (must be > 0) |
|
||||
|
||||
### Usage with `thermal_couplings`
|
||||
|
||||
```json
|
||||
"components": [
|
||||
{ "type": "BrineSource", "name": "cw_in", "fluid": "Water",
|
||||
"p_set_bar": 2.0, "t_set_c": 30.0 },
|
||||
{ "type": "ThermalLoad", "name": "cw_load", "mass_flow_kg_s": 0.9 },
|
||||
{ "type": "BrineSink", "name": "cw_out", "fluid": "Water", "p_back_bar": 2.0 }
|
||||
],
|
||||
...
|
||||
"thermal_couplings": [
|
||||
{ "hot_circuit": 0, "cold_circuit": 1, "ua": 5000.0, "efficiency": 1.0,
|
||||
"hot_component": "cond", "cold_component": "cw_load" }
|
||||
]
|
||||
```
|
||||
|
||||
The coupling owns one unknown Q closed against the hot component's measured
|
||||
duty (`Q = η·duty_hot` via `measure_output(Capacity)`); the `ThermalLoad`
|
||||
consumes Q in r1, so the heat genuinely crosses the circuit boundary and the
|
||||
First Law closes across circuits. Keep the water-loop conditions consistent
|
||||
with the hot component's secondary stream (same T_in, ṁ, cp).
|
||||
|
||||
Full example: `crates/cli/examples/chiller_r410a_coupled_water_loop.json`.
|
||||
|
||||
---
|
||||
|
||||
## FR
|
||||
|
||||
### Modèle physique
|
||||
|
||||
`ThermalLoad` modélise un segment de charge hydronique — par exemple le côté
|
||||
eau de refroidissement d'un échangeur partagé — qui reçoit une **puissance
|
||||
thermique Q [W] déterminée extérieurement** par la couche de couplage
|
||||
thermique du solveur.
|
||||
|
||||
Il suit le pattern de frontières BOLT/Modelica
|
||||
(`BOLT.BoundaryNode.Coolant.Source → HX → Sink`) : la pression et la
|
||||
température d'entrée de la boucle sont fixées par des **composants
|
||||
frontières**, pas par la charge :
|
||||
|
||||
```text
|
||||
BrineSource(P_set, T_in) ──arête──▶ ThermalLoad ──arête──▶ BrineSink(P_back, T libre)
|
||||
```
|
||||
|
||||
La température de sortie est **émergente** : `T_out = T_in + Q / (ṁ·cp)`
|
||||
(laisser la température du sink libre — ne pas mettre `t_set_c` sur le
|
||||
`BrineSink`, sinon la boucle est surdéterminée).
|
||||
|
||||
### Équations résiduelles — `n_equations() = 2`
|
||||
|
||||
Débit imposé (`ṁ = ṁ_design`) + bilan d'énergie
|
||||
(`ṁ_design·(h_out − h_in) = Q_ext`). Le bilan utilise le débit de *conception*
|
||||
(constante) : r0 épingle déjà ṁ, et la forme constante garde le bloc linéaire
|
||||
et structurellement non singulier même si l'initialiseur part de ṁ = 0.
|
||||
|
||||
`Q_ext` est lu depuis l'inconnu d'état du couplage, câblé par
|
||||
`System::finalize()` via `set_external_heat_index`. Non câblé ⇒ `Q_ext = 0`
|
||||
(passage adiabatique).
|
||||
|
||||
### Bilan DoF (boucle d'eau)
|
||||
|
||||
Inconnues : 1 ṁ (branche partagée) + 2×(P,h) + 1 Q = 6.
|
||||
Équations : BrineSource 2 + ThermalLoad 2 + BrineSink 1 (T libre) + couplage 1 = 6. ✓
|
||||
|
||||
### `energy_transfers` et performance
|
||||
|
||||
`(Q_ext, 0)` — chaleur reçue positive. Le composant est **exclu de
|
||||
l'agrégation de performance du cycle** (`counts_in_cycle_performance() =
|
||||
false`) : le Q absorbé est la puissance rejetée du cycle primaire. Il
|
||||
participe néanmoins à la validation du 1er principe par composant.
|
||||
|
||||
### Paramètres JSON (CLI)
|
||||
|
||||
`mass_flow_kg_s` (kg/s, défaut 0.5) — débit de conception imposé.
|
||||
P et T_in se règlent sur le `BrineSource` ; P_back sur le `BrineSink`.
|
||||
|
||||
Exemple complet : `crates/cli/examples/chiller_r410a_coupled_water_loop.json`.
|
||||
32
apps/web/public/examples/bphx_evaporator_condenser.json
Normal file
32
apps/web/public/examples/bphx_evaporator_condenser.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "BPHX Evaporator and Condenser Bounded Test",
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"components": [
|
||||
{ "type": "RefrigerantSource", "name": "src", "fluid": "R134a", "p_set_bar": 5.0, "quality": 0.3 },
|
||||
{ "type": "BphxEvaporator", "name": "evap", "ua": 2000.0, "refrigerant": "R134a", "secondary_fluid": "Water", "secondary_inlet_temp_c": 12.0, "secondary_mass_flow_kg_s": 0.5, "secondary_cp_j_per_kgk": 4186.0 },
|
||||
{ "type": "RefrigerantSink", "name": "sink", "fluid": "R134a", "p_back_bar": 5.0 }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "src:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "sink:inlet" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"components": [
|
||||
{ "type": "RefrigerantSource", "name": "src2", "fluid": "R134a", "p_set_bar": 15.0, "quality": 1.0 },
|
||||
{ "type": "BphxCondenser", "name": "cond", "ua": 2000.0, "refrigerant": "R134a", "secondary_fluid": "Water", "secondary_inlet_temp_c": 30.0, "secondary_mass_flow_kg_s": 0.4, "secondary_cp_j_per_kgk": 4186.0 },
|
||||
{ "type": "RefrigerantSink", "name": "sink2", "fluid": "R134a", "p_back_bar": 15.0 }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "src2:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "sink2:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"solver": { "strategy": "fallback", "max_iterations": 100, "tolerance": 1e-6 }
|
||||
}
|
||||
30
apps/web/public/examples/capillary_tube_r134a.json
Normal file
30
apps/web/public/examples/capillary_tube_r134a.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Capillary smoke",
|
||||
"components": [
|
||||
{
|
||||
"type": "CapillaryTube",
|
||||
"name": "cap",
|
||||
"diameter_m": 0.0012,
|
||||
"length_m": 1.8,
|
||||
"n_segments": 24,
|
||||
"p_inlet_bar": 12.0,
|
||||
"h_inlet_kj_kg": 250.0,
|
||||
"p_outlet_bar": 3.5,
|
||||
"h_outlet_kj_kg": 250.0
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
],
|
||||
"solver": {
|
||||
"strategy": "newton",
|
||||
"max_iterations": 50,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
107
apps/web/public/examples/chiller_aircooled_r134a.json
Normal file
107
apps/web/public/examples/chiller_aircooled_r134a.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "Air-Cooled Chiller R134a (4-Port Modelica Style)",
|
||||
"description": "Full emergent-pressure chiller. Condenser on air (AirSource→cond→AirSink), evaporator on chilled water (BrineSource→evap→BrineSink). MassFlowSource_T: Free P + Fixed ṁ/T; sinks Fixed P. secondary_humidity_ratio MUST match AirSource psychrometrics (W at T_dry, RH, P).",
|
||||
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Refrigerant + secondary loops",
|
||||
"components": [
|
||||
{
|
||||
"type": "IsentropicCompressor",
|
||||
"name": "comp",
|
||||
"isentropic_efficiency": 0.70,
|
||||
"t_cond_k": 318.15,
|
||||
"t_evap_k": 278.15,
|
||||
"superheat_k": 5.0,
|
||||
"emergent_pressure": true,
|
||||
"displacement_m3": 6.5e-5,
|
||||
"speed_hz": 50.0,
|
||||
"volumetric_efficiency": 0.92
|
||||
},
|
||||
{
|
||||
"type": "Condenser",
|
||||
"name": "cond",
|
||||
"ua": 2500.0,
|
||||
"emergent_pressure": true,
|
||||
"subcooling_k": 5.0,
|
||||
"secondary_fluid": "Air",
|
||||
"secondary_humidity_ratio": 0.01412,
|
||||
"dp_model": "isobaric",
|
||||
"secondary_rated_pressure_drop_pa": 150,
|
||||
"secondary_rated_m_flow_kg_s": 1.2
|
||||
},
|
||||
{
|
||||
"type": "IsenthalpicExpansionValve",
|
||||
"name": "exv",
|
||||
"t_evap_k": 278.15,
|
||||
"emergent_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "Evaporator",
|
||||
"name": "evap",
|
||||
"ua": 1468.0,
|
||||
"emergent_pressure": true,
|
||||
"secondary_fluid": "Water",
|
||||
"dp_model": "isobaric",
|
||||
"secondary_rated_pressure_drop_pa": 40000,
|
||||
"secondary_rated_m_flow_kg_s": 0.4778
|
||||
},
|
||||
{
|
||||
"type": "AirSource",
|
||||
"name": "cond_air_in",
|
||||
"p_set_bar": 1.01325,
|
||||
"t_dry_c": 35.0,
|
||||
"rh": 40.0,
|
||||
"m_flow_kg_s": 1.2,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "AirSink",
|
||||
"name": "cond_air_out",
|
||||
"p_back_bar": 1.01325,
|
||||
"fix_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "evap_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 3.0,
|
||||
"t_set_c": 12.0,
|
||||
"m_flow_kg_s": 0.4778,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "evap_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 3.0,
|
||||
"fix_pressure": true
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "comp:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "comp:inlet" },
|
||||
{ "from": "cond_air_in:outlet", "to": "cond:secondary_inlet" },
|
||||
{ "from": "cond:secondary_outlet", "to": "cond_air_out:inlet" },
|
||||
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"solver": {
|
||||
"strategy": "newton",
|
||||
"max_iterations": 300,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
107
apps/web/public/examples/chiller_flooded_4port_watercooled.json
Normal file
107
apps/web/public/examples/chiller_flooded_4port_watercooled.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "Water-cooled chiller with FloodedEvaporator (4-port, square DoF)",
|
||||
"description": "Honest machine topology: emergent refrigerant pressures + live secondary water loops. Flooded evaporator has NO quality_control residual (compressor suction). Budget target: n_eq = n_unk (19).",
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Refrigerant + secondary loops",
|
||||
"components": [
|
||||
{
|
||||
"type": "IsentropicCompressor",
|
||||
"name": "comp",
|
||||
"isentropic_efficiency": 0.70,
|
||||
"t_cond_k": 313.15,
|
||||
"t_evap_k": 278.15,
|
||||
"superheat_k": 5.0,
|
||||
"emergent_pressure": true,
|
||||
"displacement_m3": 5.0e-5,
|
||||
"speed_hz": 50.0,
|
||||
"volumetric_efficiency": 0.92
|
||||
},
|
||||
{
|
||||
"type": "Condenser",
|
||||
"name": "cond",
|
||||
"ua": 2200.0,
|
||||
"emergent_pressure": true,
|
||||
"subcooling_k": 5.0,
|
||||
"secondary_fluid": "Water",
|
||||
"dp_model": "msh",
|
||||
"tube_length_m": 6.0,
|
||||
"tube_diameter_m": 0.0095,
|
||||
"n_parallel_tubes": 2,
|
||||
"secondary_rated_pressure_drop_pa": 30000,
|
||||
"secondary_rated_m_flow_kg_s": 0.45
|
||||
},
|
||||
{
|
||||
"type": "IsenthalpicExpansionValve",
|
||||
"name": "exv",
|
||||
"t_evap_k": 278.15,
|
||||
"emergent_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "FloodedEvaporator",
|
||||
"name": "evap",
|
||||
"ua": 9000.0,
|
||||
"refrigerant": "R134a",
|
||||
"secondary_fluid": "Water",
|
||||
"quality_control": false,
|
||||
"secondary_rated_pressure_drop_pa": 40000,
|
||||
"secondary_rated_m_flow_kg_s": 0.55
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "cond_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 2.0,
|
||||
"t_set_c": 30.0,
|
||||
"m_flow_kg_s": 0.45,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "cond_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 2.0,
|
||||
"fix_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "evap_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 3.0,
|
||||
"t_set_c": 12.0,
|
||||
"m_flow_kg_s": 0.55,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "evap_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 3.0,
|
||||
"fix_pressure": true
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "comp:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "comp:inlet" },
|
||||
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
|
||||
{ "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
|
||||
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"solver": {
|
||||
"strategy": "newton",
|
||||
"max_iterations": 300,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
105
apps/web/public/examples/chiller_flooded_delta_t_rating.json
Normal file
105
apps/web/public/examples/chiller_flooded_delta_t_rating.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "Water-cooled chiller — ΔT rating on evaporator loop",
|
||||
"description": "Evap loop: Free ṁ + Fixed T_out=7 °C (ΔT=−5 K from 12 °C). Cond loop keeps Fixed ṁ (stable anchor).",
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Refrigerant + secondary loops",
|
||||
"components": [
|
||||
{
|
||||
"type": "IsentropicCompressor",
|
||||
"name": "comp",
|
||||
"isentropic_efficiency": 0.70,
|
||||
"t_cond_k": 313.15,
|
||||
"t_evap_k": 278.15,
|
||||
"superheat_k": 5.0,
|
||||
"emergent_pressure": true,
|
||||
"displacement_m3": 5.0e-5,
|
||||
"speed_hz": 50.0,
|
||||
"volumetric_efficiency": 0.92
|
||||
},
|
||||
{
|
||||
"type": "Condenser",
|
||||
"name": "cond",
|
||||
"ua": 2200.0,
|
||||
"emergent_pressure": true,
|
||||
"subcooling_k": 5.0,
|
||||
"secondary_fluid": "Water",
|
||||
"dp_model": "msh",
|
||||
"tube_length_m": 6.0,
|
||||
"tube_diameter_m": 0.0095,
|
||||
"n_parallel_tubes": 2
|
||||
},
|
||||
{
|
||||
"type": "IsenthalpicExpansionValve",
|
||||
"name": "exv",
|
||||
"t_evap_k": 278.15,
|
||||
"emergent_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "FloodedEvaporator",
|
||||
"name": "evap",
|
||||
"ua": 9000.0,
|
||||
"refrigerant": "R134a",
|
||||
"secondary_fluid": "Water",
|
||||
"quality_control": false
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "cond_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 2.0,
|
||||
"t_set_c": 30.0,
|
||||
"m_flow_kg_s": 0.45,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "cond_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 2.0,
|
||||
"fix_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "evap_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 3.0,
|
||||
"t_set_c": 12.0,
|
||||
"m_flow_kg_s": 0.55,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": false
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "evap_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 3.0,
|
||||
"t_set_c": 7.0,
|
||||
"fix_pressure": true,
|
||||
"fix_temperature": true
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "comp:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "comp:inlet" },
|
||||
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
|
||||
{ "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
|
||||
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"solver": {
|
||||
"strategy": "newton",
|
||||
"max_iterations": 300,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
28
apps/web/public/examples/chiller_r134a_exv_orifice.json
Normal file
28
apps/web/public/examples/chiller_r134a_exv_orifice.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
"circuits": [{
|
||||
"id": 0,
|
||||
"components": [
|
||||
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.70, "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 6.5e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92 },
|
||||
{ "type": "Condenser", "name": "cond", "ua": 766.0, "emergent_pressure": true, "subcooling_k": 5.0, "secondary_fluid": "Water" },
|
||||
{ "type": "IsenthalpicExpansionValve", "name": "exv", "t_evap_k": 278.15, "emergent_pressure": true, "orifice_kv": 2.0e-6, "orifice_opening_init": 0.5, "orifice_opening_min": 0.02, "orifice_opening_max": 1.0 },
|
||||
{ "type": "Evaporator", "name": "evap", "ua": 1468.0, "emergent_pressure": true, "secondary_fluid": "Water" },
|
||||
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
|
||||
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
|
||||
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
|
||||
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "comp:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "comp:inlet" },
|
||||
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
|
||||
{ "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
|
||||
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
|
||||
]
|
||||
}],
|
||||
"solver": { "strategy": "fallback", "max_iterations": 300, "tolerance": 1e-6 }
|
||||
}
|
||||
93
apps/web/public/examples/chiller_r410a_full_physics.json
Normal file
93
apps/web/public/examples/chiller_r410a_full_physics.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "Chiller R410A - Full Physics 4-Port (Newton convergence test)",
|
||||
"description": "Cycle frigorifique complet avec IsentropicCompressor, Condenser, IsenthalpicExpansionValve et Evaporator en mode Modelica 4-port. Les cotes secondaires (eau condenseur + eau glacee) sont de vraies aretes du graphe (BrineSource → HX:secondary_inlet → HX:secondary_outlet → BrineSink), pas des parametres fixes. Le duty Q emerge du bilan ε-NTU couple a l'etat live des aretes secondaires.",
|
||||
|
||||
"fluid": "R410A",
|
||||
"fluid_backend": "CoolProp",
|
||||
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Refrigerant + secondary loops",
|
||||
"components": [
|
||||
{
|
||||
"type": "IsentropicCompressor",
|
||||
"name": "comp",
|
||||
"isentropic_efficiency": 0.75,
|
||||
"t_cond_k": 323.15,
|
||||
"t_evap_k": 275.15,
|
||||
"superheat_k": 5.0,
|
||||
"emergent_pressure": true,
|
||||
"displacement_m3": 5.0e-5,
|
||||
"speed_hz": 50.0,
|
||||
"volumetric_efficiency": 0.92
|
||||
},
|
||||
{
|
||||
"type": "Condenser",
|
||||
"name": "cond",
|
||||
"ua": 2000,
|
||||
"emergent_pressure": true,
|
||||
"subcooling_k": 5.0,
|
||||
"secondary_fluid": "Water"
|
||||
},
|
||||
{
|
||||
"type": "IsenthalpicExpansionValve",
|
||||
"name": "exv",
|
||||
"t_evap_k": 275.15,
|
||||
"emergent_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "Evaporator",
|
||||
"name": "evap",
|
||||
"ua": 1800,
|
||||
"emergent_pressure": true,
|
||||
"secondary_fluid": "Water"
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "cond_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 2.0,
|
||||
"t_set_c": 30.0,
|
||||
"m_flow_kg_s": 0.40
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "cond_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 2.0
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "evap_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 3.0,
|
||||
"t_set_c": 12.0,
|
||||
"m_flow_kg_s": 0.50
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "evap_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 3.0
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "comp:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "comp:inlet" },
|
||||
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
|
||||
{ "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
|
||||
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"solver": {
|
||||
"strategy": "fallback",
|
||||
"max_iterations": 300,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
93
apps/web/public/examples/chiller_watercooled_r410a.json
Normal file
93
apps/web/public/examples/chiller_watercooled_r410a.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "Water-Cooled Chiller R410A (4-Port Modelica Style)",
|
||||
"description": "Full emergent-pressure chiller cycle with both heat exchangers on water loops. Condenser water: BrineSource(30C) → cond:secondary_inlet → cond:secondary_outlet → BrineSink. Chilled water: BrineSource(12C) → evap:secondary_inlet → evap:secondary_outlet → BrineSink. Secondary sides are real graph edges — the duty Q is solved from the live edge state.",
|
||||
|
||||
"fluid": "R410A",
|
||||
"fluid_backend": "CoolProp",
|
||||
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Refrigerant + secondary loops",
|
||||
"components": [
|
||||
{
|
||||
"type": "IsentropicCompressor",
|
||||
"name": "comp",
|
||||
"isentropic_efficiency": 0.70,
|
||||
"t_cond_k": 313.15,
|
||||
"t_evap_k": 276.15,
|
||||
"superheat_k": 5.0,
|
||||
"emergent_pressure": true,
|
||||
"displacement_m3": 5.0e-5,
|
||||
"speed_hz": 50.0,
|
||||
"volumetric_efficiency": 0.92
|
||||
},
|
||||
{
|
||||
"type": "Condenser",
|
||||
"name": "cond",
|
||||
"ua": 2000.0,
|
||||
"emergent_pressure": true,
|
||||
"subcooling_k": 5.0,
|
||||
"secondary_fluid": "Water"
|
||||
},
|
||||
{
|
||||
"type": "IsenthalpicExpansionValve",
|
||||
"name": "exv",
|
||||
"t_evap_k": 276.15,
|
||||
"emergent_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "Evaporator",
|
||||
"name": "evap",
|
||||
"ua": 1800.0,
|
||||
"emergent_pressure": true,
|
||||
"secondary_fluid": "Water"
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "cond_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 2.0,
|
||||
"t_set_c": 30.0,
|
||||
"m_flow_kg_s": 0.40
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "cond_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 2.0
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "evap_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 3.0,
|
||||
"t_set_c": 12.0,
|
||||
"m_flow_kg_s": 0.50
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "evap_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 3.0
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "comp:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "comp:inlet" },
|
||||
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
|
||||
{ "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
|
||||
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"solver": {
|
||||
"strategy": "fallback",
|
||||
"max_iterations": 300,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
30
apps/web/public/examples/heatpump_r410a_reversing_valve.json
Normal file
30
apps/web/public/examples/heatpump_r410a_reversing_valve.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"fluid": "R410A",
|
||||
"fluid_backend": "CoolProp",
|
||||
"circuits": [{
|
||||
"id": 0,
|
||||
"components": [
|
||||
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.70, "t_cond_k": 313.15, "t_evap_k": 276.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 5.0e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92 },
|
||||
{ "type": "ReversingValve", "name": "rv", "pressure_drop_kpa": 25.0, "pressure_drop_coeff": 5.0e5 },
|
||||
{ "type": "Condenser", "name": "cond", "ua": 2000.0, "emergent_pressure": true, "subcooling_k": 5.0, "secondary_fluid": "Water" },
|
||||
{ "type": "IsenthalpicExpansionValve", "name": "exv", "t_evap_k": 276.15, "emergent_pressure": true },
|
||||
{ "type": "Evaporator", "name": "evap", "ua": 1800.0, "emergent_pressure": true, "secondary_fluid": "Air", "secondary_humidity_ratio": 0.010 },
|
||||
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 40.0, "m_flow_kg_s": 0.4 },
|
||||
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
|
||||
{ "type": "AirSource", "name": "evap_air_in", "p_set_bar": 1.01325, "t_dry_c": 7.0, "rh": 50.0, "m_flow_kg_s": 0.5 },
|
||||
{ "type": "AirSink", "name": "evap_air_out", "p_back_bar": 1.01325 }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "comp:outlet", "to": "rv:inlet" },
|
||||
{ "from": "rv:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "comp:inlet" },
|
||||
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
|
||||
{ "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
|
||||
{ "from": "evap_air_in:outlet", "to": "evap:secondary_inlet" },
|
||||
{ "from": "evap:secondary_outlet", "to": "evap_air_out:inlet" }
|
||||
]
|
||||
}],
|
||||
"solver": { "strategy": "fallback", "max_iterations": 300, "tolerance": 1e-6 }
|
||||
}
|
||||
88
apps/web/public/examples/hx_air_water_4port.json
Normal file
88
apps/web/public/examples/hx_air_water_4port.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "Four-Port Air-Water Heat Exchanger",
|
||||
"fluid": "Water",
|
||||
"fluid_backend": "CoolProp",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"components": [
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "hot_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 2.0,
|
||||
"t_set_c": 60.0,
|
||||
"m_flow_kg_s": 0.5
|
||||
},
|
||||
{
|
||||
"type": "HeatExchanger",
|
||||
"name": "hx",
|
||||
"ua": 3000.0,
|
||||
"hot_fluid_id": "Water",
|
||||
"cold_fluid_id": "Air",
|
||||
"cold_humidity_ratio": 0.010
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "hot_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 2.0
|
||||
},
|
||||
{
|
||||
"type": "AirSource",
|
||||
"name": "cold_air_in",
|
||||
"p_set_bar": 1.01325,
|
||||
"t_dry_c": 20.0,
|
||||
"rh": 50.0,
|
||||
"m_flow_kg_s": 1.0
|
||||
},
|
||||
{
|
||||
"type": "Fan",
|
||||
"name": "supply_fan",
|
||||
"fluid": "Air",
|
||||
"speed_ratio": 1.0,
|
||||
"air_density_kg_per_m3": 1.204,
|
||||
"design_flow_m3_s": 0.83,
|
||||
"curve_p0": 250.0,
|
||||
"curve_p1": 0.0,
|
||||
"curve_p2": -20.0,
|
||||
"eff_e0": 0.65,
|
||||
"eff_e1": 0.0,
|
||||
"eff_e2": 0.0
|
||||
},
|
||||
{
|
||||
"type": "AirSink",
|
||||
"name": "cold_air_out",
|
||||
"p_back_bar": 1.01325
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "hot_water_in:outlet",
|
||||
"to": "hx:hot_inlet"
|
||||
},
|
||||
{
|
||||
"from": "hx:hot_outlet",
|
||||
"to": "hot_water_out:inlet"
|
||||
},
|
||||
{
|
||||
"from": "cold_air_in:outlet",
|
||||
"to": "supply_fan:inlet"
|
||||
},
|
||||
{
|
||||
"from": "supply_fan:outlet",
|
||||
"to": "hx:cold_inlet"
|
||||
},
|
||||
{
|
||||
"from": "hx:cold_outlet",
|
||||
"to": "cold_air_out:inlet"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"solver": {
|
||||
"strategy": "newton",
|
||||
"max_iterations": 300,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
224
apps/web/src/app/globals.css
Normal file
224
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,224 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
Entropyk — Modelica/Dymola diagram aesthetic.
|
||||
Light engineering canvas: white diagram sheet, dotted grid, schematic
|
||||
component icons with square fluid connectors and orthogonal connections.
|
||||
Chrome is a quiet CAD-tool grey so the white diagram sheet is the focus.
|
||||
*/
|
||||
:root {
|
||||
--sheet: #ffffff; /* diagram canvas */
|
||||
--sheet-grid: #e7ecf2; /* grid dots */
|
||||
--chrome: #eef1f5; /* app chrome / panels */
|
||||
--chrome-2: #f6f8fb; /* raised inputs */
|
||||
--panel: #ffffff;
|
||||
|
||||
--line: #d6dde6; /* hairline borders */
|
||||
--line-strong: #b9c3cf;
|
||||
|
||||
--ink: #1a2330; /* primary text */
|
||||
--ink-dim: #5d6b7c; /* secondary */
|
||||
--ink-faint: #93a0b0; /* tertiary */
|
||||
|
||||
--connector: #1565c0; /* legacy default (water) */
|
||||
--wire: #36475a; /* unknown medium */
|
||||
--wire-hi: #1565c0;
|
||||
|
||||
/* TIL / Modelica HVAC medium colours */
|
||||
--media-refrigerant: #2e7d32;
|
||||
--media-water: #1565c0;
|
||||
--media-air: #f9a825;
|
||||
|
||||
--accent: #1b6fe0; /* primary action */
|
||||
--hot: #e23b2e; /* condenser / high pressure */
|
||||
--cold: #1f86e0; /* evaporator / low pressure */
|
||||
--liquid: #e0902b; /* liquid line */
|
||||
--ok: #1f9d57;
|
||||
--warn: #c9821a;
|
||||
|
||||
/* Top command bar — dark CAD chrome */
|
||||
--bar: #1c2634;
|
||||
--bar-ink: #c4cede;
|
||||
--bar-accent: #5eb0ff;
|
||||
|
||||
--font-display: var(--font-display), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-mono), ui-monospace, "SFMono-Regular", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
background: var(--chrome);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-display);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--line-strong);
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--chrome);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── React Flow — Modelica diagram sheet ────────────────────── */
|
||||
.react-flow {
|
||||
background: var(--sheet);
|
||||
}
|
||||
.react-flow__attribution {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Square fluid connectors (Modelica convention); fill set per medium in JS */
|
||||
.react-flow__handle {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 0;
|
||||
background: var(--connector);
|
||||
border: 1px solid rgba(15, 28, 45, 0.55);
|
||||
transition: transform 0.1s ease, filter 0.1s ease;
|
||||
}
|
||||
.react-flow__handle:hover,
|
||||
.react-flow__handle.connectingfrom,
|
||||
.react-flow__handle.connectingto {
|
||||
transform: scale(1.25);
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
.react-flow__handle-left {
|
||||
left: -6px;
|
||||
}
|
||||
.react-flow__handle-right {
|
||||
right: -6px;
|
||||
}
|
||||
.react-flow__handle-top {
|
||||
top: -6px;
|
||||
}
|
||||
.react-flow__handle-bottom {
|
||||
bottom: -6px;
|
||||
}
|
||||
|
||||
.react-flow__edge-path {
|
||||
stroke: var(--wire);
|
||||
stroke-width: 1.75;
|
||||
}
|
||||
.react-flow__edge.selected .react-flow__edge-path,
|
||||
.react-flow__edge:hover .react-flow__edge-path {
|
||||
stroke: var(--wire-hi);
|
||||
stroke-width: 2.25;
|
||||
}
|
||||
.react-flow__connection-path {
|
||||
stroke: var(--connector);
|
||||
stroke-width: 1.75;
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.react-flow__controls {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(20, 40, 70, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.react-flow__controls-button {
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--ink-dim);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.react-flow__controls-button:hover {
|
||||
background: var(--chrome-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.react-flow__controls-button svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.react-flow__node.selected {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ── Bibliothèque — accordion + stagger reveal ──────────────── */
|
||||
.palette-cat-body {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.palette-cat-body.is-open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
.palette-cat-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@keyframes palette-item-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.palette-cat-body.is-open .palette-item {
|
||||
animation: palette-item-in 200ms ease-out both;
|
||||
animation-delay: calc(var(--i, 0) * 28ms);
|
||||
}
|
||||
|
||||
.palette-cat-toggle {
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.ek-node {
|
||||
transition: box-shadow 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.palette-cat-body {
|
||||
transition: none;
|
||||
}
|
||||
.palette-cat-body.is-open .palette-item {
|
||||
animation: none;
|
||||
}
|
||||
.ek-node {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
32
apps/web/src/app/layout.tsx
Normal file
32
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Space_Grotesk, IBM_Plex_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const display = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-display",
|
||||
});
|
||||
|
||||
const mono = IBM_Plex_Mono({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
variable: "--font-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Entropyk — Cycle Workbench",
|
||||
description: "Design and solve refrigeration, heat-pump and HVAC cycles on a live schematic.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${display.variable} ${mono.variable}`}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/app/page.tsx
Normal file
55
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import ComponentPalette from "@/components/palette/ComponentPalette";
|
||||
import PropertiesPanel from "@/components/panels/PropertiesPanel";
|
||||
import ResultsPanel from "@/components/panels/ResultsPanel";
|
||||
import DofStatusBar from "@/components/DofStatusBar";
|
||||
import Toolbar from "@/components/Toolbar";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
|
||||
// React Flow needs the DOM — load only on the client.
|
||||
const Canvas = dynamic(() => import("@/components/canvas/Canvas"), { ssr: false });
|
||||
|
||||
export default function Page() {
|
||||
const { result, simError, lastConfig, nodes, simulating } = useDiagramStore();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<Toolbar />
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<ComponentPalette />
|
||||
|
||||
<div className="relative flex-1">
|
||||
<Canvas />
|
||||
{nodes.length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
|
||||
{/* Right column: parameter dialog + results, stacked & scrollable */}
|
||||
<div className="flex w-96 flex-col overflow-y-auto border-l border-[var(--line)] bg-[var(--panel)]">
|
||||
<PropertiesPanel />
|
||||
<div className="h-2 bg-[var(--chrome)]" />
|
||||
<ResultsPanel result={result} error={simError} config={lastConfig} simulating={simulating} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Live DoF indicator: equations vs unknowns (fix/free discipline) */}
|
||||
<DofStatusBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHint() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center">
|
||||
<div className="max-w-sm text-center">
|
||||
<p className="text-[15px] font-semibold text-[var(--ink-dim)]">Start your cycle</p>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-[var(--ink-faint)]">
|
||||
Drag parts from the library onto the sheet, then wire ports. Select a part,
|
||||
then use the top bar, right-click menu, or{" "}
|
||||
<span className="mono">R</span>/<span className="mono">H</span>/
|
||||
<span className="mono">V</span>. Drop a pipe on a line to insert it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
apps/web/src/components/DofStatusBar.tsx
Normal file
230
apps/web/src/components/DofStatusBar.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
import { buildDofCoach, type CoachSeverity } from "@/lib/dofCoach";
|
||||
import type { DofBalance } from "@/lib/dofLedger";
|
||||
import type { EntropykNodeData } from "@/lib/configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronUp,
|
||||
Equal,
|
||||
Lightbulb,
|
||||
MinusCircle,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
function balanceStyle(balance: DofBalance): {
|
||||
bg: string;
|
||||
fg: string;
|
||||
border: string;
|
||||
Icon: typeof CheckCircle2;
|
||||
} {
|
||||
switch (balance) {
|
||||
case "balanced":
|
||||
return {
|
||||
bg: "bg-emerald-50",
|
||||
fg: "text-emerald-800",
|
||||
border: "border-emerald-200",
|
||||
Icon: CheckCircle2,
|
||||
};
|
||||
case "over-constrained":
|
||||
return {
|
||||
bg: "bg-red-50",
|
||||
fg: "text-red-800",
|
||||
border: "border-red-200",
|
||||
Icon: AlertTriangle,
|
||||
};
|
||||
case "under-constrained":
|
||||
return {
|
||||
bg: "bg-amber-50",
|
||||
fg: "text-amber-900",
|
||||
border: "border-amber-200",
|
||||
Icon: MinusCircle,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: "bg-[var(--chrome-2)]",
|
||||
fg: "text-[var(--ink-dim)]",
|
||||
border: "border-[var(--line)]",
|
||||
Icon: Equal,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function tipIcon(sev: CoachSeverity) {
|
||||
switch (sev) {
|
||||
case "ok":
|
||||
return <CheckCircle2 size={12} className="mt-0.5 shrink-0 text-emerald-600" />;
|
||||
case "block":
|
||||
return <AlertTriangle size={12} className="mt-0.5 shrink-0 text-red-600" />;
|
||||
case "warn":
|
||||
return <AlertTriangle size={12} className="mt-0.5 shrink-0 text-amber-600" />;
|
||||
default:
|
||||
return <Lightbulb size={12} className="mt-0.5 shrink-0 text-[var(--accent)]" />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function DofStatusBar() {
|
||||
const nodes = useDiagramStore((s) => s.nodes) as Node<EntropykNodeData>[];
|
||||
const edges = useDiagramStore((s) => s.edges);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<"guide" | "ledger">("guide");
|
||||
|
||||
const coach = useMemo(() => buildDofCoach(nodes, edges), [nodes, edges]);
|
||||
const { ledger } = coach;
|
||||
const style = balanceStyle(ledger.balance);
|
||||
const Icon = style.Icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-t ${style.border} ${style.bg} text-[11px] ${style.fg}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-3 py-1.5 text-left transition-colors hover:brightness-[0.98]"
|
||||
title="Guide de balance DoF — cliquer pour les étapes"
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="font-semibold tracking-tight">
|
||||
{ledger.balance === "balanced"
|
||||
? "Balance OK"
|
||||
: ledger.balance === "over-constrained"
|
||||
? "Trop de Fixed"
|
||||
: ledger.balance === "under-constrained"
|
||||
? "Manque de Fixed"
|
||||
: "Balance"}
|
||||
</span>
|
||||
|
||||
<span className="mono flex items-center gap-1.5 rounded border border-black/5 bg-white/70 px-2 py-0.5 font-medium">
|
||||
<span title="Équations">{ledger.nEquations}</span>
|
||||
<span className="text-[var(--ink-faint)]">
|
||||
{ledger.balance === "balanced"
|
||||
? "="
|
||||
: ledger.balance === "over-constrained"
|
||||
? ">"
|
||||
: "<"}
|
||||
</span>
|
||||
<span title="Inconnues">{ledger.nUnknowns}</span>
|
||||
</span>
|
||||
|
||||
<span className="hidden min-w-0 flex-1 truncate sm:inline text-[10px] opacity-90">
|
||||
{coach.headline}
|
||||
</span>
|
||||
|
||||
<span className="mono ml-auto hidden items-center gap-2 text-[10px] text-[var(--ink-faint)] md:flex">
|
||||
<Sparkles size={11} className="text-[var(--accent)]" />
|
||||
<span>{coach.tips.length} tip{coach.tips.length > 1 ? "s" : ""}</span>
|
||||
<span>·</span>
|
||||
<span>{ledger.nEdges} edges</span>
|
||||
<span>·</span>
|
||||
<span>{ledger.nBranches} ṁ</span>
|
||||
</span>
|
||||
|
||||
<ChevronUp
|
||||
size={14}
|
||||
className={`ml-1 shrink-0 transition-transform ${open ? "" : "rotate-180"}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="max-h-64 overflow-y-auto border-t border-black/5 bg-white/85 px-3 py-2 text-[10px] leading-relaxed text-[var(--ink-dim)]">
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("guide")}
|
||||
className={`rounded px-2 py-0.5 text-[10px] font-medium ${
|
||||
tab === "guide"
|
||||
? "bg-[var(--ink)] text-white"
|
||||
: "bg-[var(--chrome-2)] text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
Guide
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("ledger")}
|
||||
className={`rounded px-2 py-0.5 text-[10px] font-medium ${
|
||||
tab === "ledger"
|
||||
? "bg-[var(--ink)] text-white"
|
||||
: "bg-[var(--chrome-2)] text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
Ledger
|
||||
</button>
|
||||
<span className="ml-auto text-[9px] text-[var(--ink-faint)]">
|
||||
Estimation UI — le CLI valide le ledger Rust exact
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tab === "guide" ? (
|
||||
<>
|
||||
<p className="mb-2 text-[10px] text-[var(--ink-faint)]">
|
||||
Chaque <strong className="text-[var(--ink)]">Fixed</strong> doit libérer une
|
||||
inconnue ailleurs (Free P, actionneur, pression émergente…). Suit les étapes dans
|
||||
l’ordre.
|
||||
</p>
|
||||
<ol className="space-y-1.5">
|
||||
{coach.tips.map((tip, i) => (
|
||||
<li
|
||||
key={tip.id}
|
||||
className="flex gap-2 rounded border border-[var(--line)] bg-white px-2 py-1.5"
|
||||
>
|
||||
<span className="mono mt-0.5 w-4 shrink-0 text-[9px] font-bold text-[var(--ink-faint)]">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{tipIcon(tip.severity)}
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-[var(--ink)]">{tip.title}</div>
|
||||
<div className="text-[var(--ink-dim)]">{tip.action}</div>
|
||||
{tip.focus && (
|
||||
<div className="mono mt-0.5 text-[9px] text-[var(--accent)]">
|
||||
→ {tip.focus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{ledger.diagnostics.length > 0 && (
|
||||
<ul className="mb-2 space-y-0.5">
|
||||
{ledger.diagnostics.slice(0, 12).map((d, i) => (
|
||||
<li key={i} className="flex gap-1.5">
|
||||
<span className="text-amber-600">!</span>
|
||||
<span>{d}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ledger.components.map((c) => (
|
||||
<div
|
||||
key={`${c.name}-${c.type}`}
|
||||
className="rounded border border-[var(--line)] bg-white px-2 py-1"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="mono font-semibold text-[var(--ink)]">{c.name}</span>
|
||||
<span className="mono text-[var(--ink-faint)]">{c.nEquations} eq</span>
|
||||
</div>
|
||||
<div className="mono truncate text-[9px] text-[var(--ink-faint)]">{c.type}</div>
|
||||
<div className="mt-0.5 truncate text-[9px] text-[var(--ink-dim)]">
|
||||
{c.roles.join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
apps/web/src/components/Toolbar.tsx
Normal file
348
apps/web/src/components/Toolbar.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
Play,
|
||||
Loader2,
|
||||
FileDown,
|
||||
FileUp,
|
||||
Eraser,
|
||||
Boxes,
|
||||
Grid3x3,
|
||||
RotateCw,
|
||||
Layers,
|
||||
FlipHorizontal2,
|
||||
FlipVertical2,
|
||||
} from "lucide-react";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
import { buildScenarioConfig, validateConfig } from "@/lib/configBuilder";
|
||||
import { computeDofLedger } from "@/lib/dofLedger";
|
||||
import { simulate } from "@/lib/api";
|
||||
import type { EntropykNodeData } from "@/lib/configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import MultiRunPanel from "@/components/panels/MultiRunPanel";
|
||||
|
||||
const FLUIDS = ["R410A", "R134a", "R290", "R744", "R32", "R1234yf", "Water", "Air"];
|
||||
const BACKENDS = ["CoolProp", "Test"];
|
||||
const SOLVERS = [
|
||||
{ value: "newton", label: "Newton" },
|
||||
{ value: "picard", label: "Picard" },
|
||||
];
|
||||
|
||||
const EXAMPLES = [
|
||||
{ file: "hx_air_water_4port.json", label: "HX air–water" },
|
||||
{ file: "chiller_flooded_4port_watercooled.json", label: "Chiller flooded" },
|
||||
{ file: "chiller_watercooled_r410a.json", label: "Chiller R410A" },
|
||||
{ file: "chiller_aircooled_r134a.json", label: "Chiller air-cooled" },
|
||||
{ file: "bphx_evaporator_condenser.json", label: "BPHX cycle" },
|
||||
{ file: "heatpump_r410a_reversing_valve.json", label: "Heat pump 4-way" },
|
||||
{ file: "chiller_r134a_exv_orifice.json", label: "EXV orifice" },
|
||||
{ file: "chiller_r410a_full_physics.json", label: "R410A full physics" },
|
||||
{ file: "capillary_tube_r134a.json", label: "Capillary smoke" },
|
||||
];
|
||||
|
||||
export default function Toolbar() {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
fluid,
|
||||
fluidBackend,
|
||||
solverStrategy,
|
||||
maxIterations,
|
||||
tolerance,
|
||||
snapToGrid,
|
||||
selectedNodeId,
|
||||
setFluid,
|
||||
setFluidBackend,
|
||||
setSolverStrategy,
|
||||
setLastConfig,
|
||||
toggleSnapToGrid,
|
||||
rotateNode,
|
||||
flipNodeH,
|
||||
flipNodeV,
|
||||
setResult,
|
||||
setSimulating,
|
||||
simulating,
|
||||
loadFromConfig,
|
||||
clear,
|
||||
} = useDiagramStore();
|
||||
|
||||
const [issues, setIssues] = useState<string[]>([]);
|
||||
const [multiOpen, setMultiOpen] = useState(false);
|
||||
const [exampleFile, setExampleFile] = useState(EXAMPLES[0].file);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const onSimulate = async () => {
|
||||
const problems = validateConfig(nodes, edges);
|
||||
const ledger = computeDofLedger(nodes as Node<EntropykNodeData>[], edges);
|
||||
// Hard block only when over-constrained (no free lunch). Under-constrained
|
||||
// diagrams are common while editing — surface as a soft issue after simulate
|
||||
// via the status bar; the CLI still hard-fails on non-square systems.
|
||||
if (ledger.balance === "over-constrained") {
|
||||
problems.push(
|
||||
`DoF over-constrained: ${ledger.nEquations} equations > ${ledger.nUnknowns} unknowns. ` +
|
||||
`Remove a FIX (quality control, extra outlet closure) or FREE an actuator.`,
|
||||
);
|
||||
}
|
||||
setIssues(problems);
|
||||
if (problems.length > 0) return;
|
||||
|
||||
const config = buildScenarioConfig(nodes, edges, {
|
||||
fluid,
|
||||
fluidBackend,
|
||||
solverStrategy,
|
||||
maxIterations,
|
||||
tolerance,
|
||||
});
|
||||
setSimulating(true);
|
||||
setLastConfig(config);
|
||||
setResult(null, null);
|
||||
try {
|
||||
const resp = await simulate(config);
|
||||
if (resp.ok && resp.result) {
|
||||
setResult(resp.result, null);
|
||||
if (resp.result.dof && resp.result.dof.n_equations !== resp.result.dof.n_unknowns) {
|
||||
setIssues([
|
||||
`Server DoF: ${resp.result.dof.n_equations} eqs vs ${resp.result.dof.n_unknowns} unk (${resp.result.dof.balance})`,
|
||||
]);
|
||||
}
|
||||
} else setResult(null, resp.error || "Simulation failed");
|
||||
} catch (e) {
|
||||
setResult(null, e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSimulating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadExample = async () => {
|
||||
try {
|
||||
const res = await fetch(`/examples/${exampleFile}`);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
loadFromConfig(await res.json());
|
||||
setIssues([]);
|
||||
} catch (e) {
|
||||
setIssues([`Could not load example: ${e instanceof Error ? e.message : e}`]);
|
||||
}
|
||||
};
|
||||
|
||||
const onImportJsonFile = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
loadFromConfig(JSON.parse(text));
|
||||
setIssues([]);
|
||||
} catch (e) {
|
||||
setIssues([`Could not import JSON: ${e instanceof Error ? e.message : e}`]);
|
||||
} finally {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-11 items-center gap-2 border-b border-black/40 bg-[var(--bar)] px-3 text-[var(--bar-ink)]">
|
||||
<div className="flex items-center gap-2 pr-1">
|
||||
<Boxes size={16} className="text-[var(--bar-accent)]" />
|
||||
<span className="mono text-[12.5px] font-semibold tracking-[0.14em] text-white">
|
||||
ENTROPYK
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bar-sep" />
|
||||
|
||||
<BarField label="Fluid">
|
||||
<select value={fluid} onChange={(e) => setFluid(e.target.value)} className="bar-select mono">
|
||||
{FLUIDS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</BarField>
|
||||
<BarField label="Props">
|
||||
<select
|
||||
value={fluidBackend}
|
||||
onChange={(e) => setFluidBackend(e.target.value)}
|
||||
className="bar-select mono"
|
||||
>
|
||||
{BACKENDS.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</BarField>
|
||||
<BarField label="Solver">
|
||||
<select
|
||||
value={solverStrategy}
|
||||
onChange={(e) => setSolverStrategy(e.target.value)}
|
||||
className="bar-select mono"
|
||||
>
|
||||
{SOLVERS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</BarField>
|
||||
|
||||
<div className="bar-sep" />
|
||||
|
||||
{/* Orientation — icon group, works on the selected part */}
|
||||
<div className="flex items-center overflow-hidden rounded-[4px] border border-white/12">
|
||||
<button
|
||||
onClick={() => selectedNodeId && rotateNode(selectedNodeId, 1)}
|
||||
disabled={!selectedNodeId}
|
||||
className="bar-icon"
|
||||
title="Rotation 90° (R) — sélectionne d’abord une pièce"
|
||||
>
|
||||
<RotateCw size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedNodeId && flipNodeH(selectedNodeId)}
|
||||
disabled={!selectedNodeId}
|
||||
className="bar-icon border-l border-white/12"
|
||||
title="Miroir horizontal (H)"
|
||||
>
|
||||
<FlipHorizontal2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedNodeId && flipNodeV(selectedNodeId)}
|
||||
disabled={!selectedNodeId}
|
||||
className="bar-icon border-l border-white/12"
|
||||
title="Miroir vertical (V)"
|
||||
>
|
||||
<FlipVertical2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleSnapToGrid}
|
||||
className="bar-icon border-l border-white/12"
|
||||
data-active={snapToGrid}
|
||||
title="Aimanter à la grille"
|
||||
>
|
||||
<Grid3x3 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{issues.length > 0 && (
|
||||
<div
|
||||
className="mono max-w-[240px] truncate text-[11px] text-amber-300"
|
||||
title={issues.join("\n")}
|
||||
>
|
||||
⚠ {issues.length} · {issues[0]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={exampleFile}
|
||||
onChange={(e) => setExampleFile(e.target.value)}
|
||||
className="bar-select mono max-w-[150px]"
|
||||
title="Exemple de scénario"
|
||||
>
|
||||
{EXAMPLES.map((ex) => (
|
||||
<option key={ex.file} value={ex.file}>
|
||||
{ex.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={onLoadExample} className="bar-btn" title="Charger l’exemple">
|
||||
<FileDown size={13} /> Load
|
||||
</button>
|
||||
<button onClick={() => fileInputRef.current?.click()} className="bar-btn" title="Importer un JSON">
|
||||
<FileUp size={13} /> Import
|
||||
</button>
|
||||
<button onClick={() => setMultiOpen(true)} className="bar-btn" title="Balayage paramétrique parallèle">
|
||||
<Layers size={13} /> Multi-run
|
||||
</button>
|
||||
<button onClick={clear} className="bar-btn" title="Vider la feuille">
|
||||
<Eraser size={13} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={(event) => void onImportJsonFile(event.target.files?.[0])}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={onSimulate}
|
||||
disabled={simulating}
|
||||
className="ml-1 flex items-center gap-1.5 rounded-[4px] bg-[var(--bar-accent)] px-4 py-[7px] text-[12.5px] font-semibold text-[#0c1420] transition-all hover:brightness-110 disabled:opacity-50"
|
||||
>
|
||||
{simulating ? <Loader2 size={13} className="animate-spin" /> : <Play size={13} />}
|
||||
{simulating ? "Solving…" : "Solve"}
|
||||
</button>
|
||||
|
||||
{multiOpen && <MultiRunPanel onClose={() => setMultiOpen(false)} />}
|
||||
|
||||
<style jsx>{`
|
||||
.bar-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
margin: 0 4px;
|
||||
}
|
||||
.bar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 5px 9px;
|
||||
font-size: 12px;
|
||||
color: var(--bar-ink);
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.bar-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
.bar-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
color: var(--bar-ink);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.bar-icon:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
.bar-icon:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.bar-icon[data-active="true"] {
|
||||
background: rgba(94, 176, 255, 0.22);
|
||||
color: var(--bar-accent);
|
||||
}
|
||||
:global(.bar-select) {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
font-size: 11.5px;
|
||||
color: var(--bar-ink);
|
||||
}
|
||||
:global(.bar-select option) {
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
}
|
||||
`}</style>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function BarField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 leading-none">
|
||||
<span className="mono text-[9px] uppercase tracking-[0.1em] text-white/45">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
399
apps/web/src/components/canvas/Canvas.tsx
Normal file
399
apps/web/src/components/canvas/Canvas.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
ReactFlow,
|
||||
MarkerType,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
type ReactFlowInstance,
|
||||
type OnNodesChange,
|
||||
type OnEdgesChange,
|
||||
type OnConnect,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { FlipHorizontal2, FlipVertical2, RotateCw } from "lucide-react";
|
||||
|
||||
import { GRID_SIZE, useDiagramStore, type EntropykNodeData } from "@/store/diagramStore";
|
||||
import { portRole } from "@/lib/componentMeta";
|
||||
import {
|
||||
MEDIA_COLOR,
|
||||
MEDIA_LABEL,
|
||||
mediaColor,
|
||||
mediaForEdge,
|
||||
type MediaKind,
|
||||
} from "@/lib/mediaStyle";
|
||||
import {
|
||||
findNearestEdge,
|
||||
isPipeType,
|
||||
pipeMediaKind,
|
||||
} from "@/lib/edgeInsert";
|
||||
import { defaultParams } from "@/lib/componentMeta";
|
||||
import EntropykNode from "./EntropykNode";
|
||||
import NodeContextMenu, { type NodeContextMenuState } from "./NodeContextMenu";
|
||||
|
||||
const nodeTypes = { entropykNode: EntropykNode };
|
||||
|
||||
/** Slightly darken / lighten a hex colour for HP vs LP within the same medium. */
|
||||
function shadeHex(hex: string, amount: number): string {
|
||||
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!m) return hex;
|
||||
const ch = (i: number) =>
|
||||
Math.max(0, Math.min(255, parseInt(m[i], 16) + amount))
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `#${ch(1)}${ch(2)}${ch(3)}`;
|
||||
}
|
||||
|
||||
export default function Canvas() {
|
||||
const rfInstance = useRef<ReactFlowInstance<Node<EntropykNodeData>, Edge> | null>(null);
|
||||
const [ctxMenu, setCtxMenu] = useState<NodeContextMenuState | null>(null);
|
||||
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
result,
|
||||
snapToGrid,
|
||||
selectedNodeId,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
addComponent,
|
||||
insertOnEdge,
|
||||
setSelected,
|
||||
rotateNode,
|
||||
flipNodeH,
|
||||
flipNodeV,
|
||||
removeNode,
|
||||
} = useDiagramStore();
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => nodes.find((n) => n.id === selectedNodeId) ?? null,
|
||||
[nodes, selectedNodeId],
|
||||
);
|
||||
|
||||
const onInit = useCallback((instance: ReactFlowInstance<Node<EntropykNodeData>, Edge>) => {
|
||||
rfInstance.current = instance;
|
||||
instance.fitView({ padding: 0.25 });
|
||||
}, []);
|
||||
|
||||
// Modelica/Dymola: R = rotate, H = flip horizontal, V = flip vertical.
|
||||
// Prefer canvas focus — skip only when typing text in a field.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
const typingText =
|
||||
!!target &&
|
||||
(tag === "TEXTAREA" ||
|
||||
target.isContentEditable ||
|
||||
(tag === "INPUT" &&
|
||||
(target as HTMLInputElement).type !== "checkbox" &&
|
||||
(target as HTMLInputElement).type !== "radio" &&
|
||||
(target as HTMLInputElement).type !== "button"));
|
||||
// Allow R/H/V from number fields only with Alt — otherwise canvas after click.
|
||||
if (typingText && !e.altKey) return;
|
||||
if (!selectedNodeId) return;
|
||||
if (e.key === "r" || e.key === "R") {
|
||||
e.preventDefault();
|
||||
rotateNode(selectedNodeId, e.shiftKey ? -1 : 1);
|
||||
} else if (e.key === "h" || e.key === "H") {
|
||||
e.preventDefault();
|
||||
flipNodeH(selectedNodeId);
|
||||
} else if (e.key === "v" || e.key === "V") {
|
||||
e.preventDefault();
|
||||
flipNodeV(selectedNodeId);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [selectedNodeId, rotateNode, flipNodeH, flipNodeV]);
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const type = e.dataTransfer.getData("application/entropyk-type");
|
||||
if (!type || !rfInstance.current) return;
|
||||
const position = rfInstance.current.screenToFlowPosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
|
||||
// Pipe / duct dropped on a wire → splice into the circuit (Modelica-style).
|
||||
if (isPipeType(type)) {
|
||||
const prefer = pipeMediaKind(type, defaultParams(type));
|
||||
const hit = findNearestEdge(position, nodes, edges, {
|
||||
maxDistance: 64,
|
||||
preferMedia: prefer,
|
||||
});
|
||||
if (hit) {
|
||||
const result = insertOnEdge(type, hit.midpoint, hit.edge.id);
|
||||
if (result.ok) return;
|
||||
// Media mismatch: fall through to free placement.
|
||||
}
|
||||
}
|
||||
|
||||
addComponent(type, position);
|
||||
},
|
||||
[addComponent, insertOnEdge, nodes, edges],
|
||||
);
|
||||
|
||||
const isValidConnection = useCallback((c: Connection | Edge) => {
|
||||
if (!c.source || !c.target || c.source === c.target) return false;
|
||||
const srcOk = !c.sourceHandle || portRole(c.sourceHandle) === "source";
|
||||
const tgtOk = !c.targetHandle || portRole(c.targetHandle) === "target";
|
||||
return srcOk && tgtOk;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Colour wires by Modelica/TIL medium (refrigerant / water / air).
|
||||
* After a solve, keep the medium hue and only shade HP vs LP slightly;
|
||||
* pressure is shown as a label.
|
||||
*/
|
||||
const styledEdges = useMemo<Edge[]>(() => {
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
const nameById = new Map(nodes.map((n) => [n.id, n.data.name]));
|
||||
|
||||
const pByPair = new Map<string, number>();
|
||||
let pMin = Infinity;
|
||||
let pMax = -Infinity;
|
||||
if (result?.state) {
|
||||
for (const s of result.state) {
|
||||
if (s.source && s.target && typeof s.pressure_pa === "number") {
|
||||
pByPair.set(`${s.source}→${s.target}`, s.pressure_pa);
|
||||
pMin = Math.min(pMin, s.pressure_pa);
|
||||
pMax = Math.max(pMax, s.pressure_pa);
|
||||
}
|
||||
}
|
||||
}
|
||||
const mid = (pMin + pMax) / 2;
|
||||
const solved = pByPair.size > 0 && pMax > pMin;
|
||||
|
||||
return edges.map((e) => {
|
||||
const kind = mediaForEdge(
|
||||
nodeById.get(e.source),
|
||||
e.sourceHandle,
|
||||
nodeById.get(e.target),
|
||||
e.targetHandle,
|
||||
);
|
||||
let color = mediaColor(kind);
|
||||
let label: string | undefined;
|
||||
if (solved) {
|
||||
const p = pByPair.get(`${nameById.get(e.source)}→${nameById.get(e.target)}`);
|
||||
if (p !== undefined) {
|
||||
// Within one medium: darker = high pressure, lighter = low pressure.
|
||||
color = p >= mid ? shadeHex(color, -28) : shadeHex(color, 36);
|
||||
label = `${(p / 1e5).toFixed(1)} bar`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...e,
|
||||
type: "smoothstep",
|
||||
animated: solved,
|
||||
style: { stroke: color, strokeWidth: solved ? 2.4 : 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color, width: 14, height: 14 },
|
||||
label,
|
||||
labelStyle: { fill: color, fontSize: 10, fontFamily: "var(--font-mono)", fontWeight: 600 },
|
||||
labelBgStyle: { fill: "#ffffff", fillOpacity: 0.92 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
labelBgBorderRadius: 3,
|
||||
data: { ...(e.data as object), media: kind },
|
||||
};
|
||||
});
|
||||
}, [edges, nodes, result]);
|
||||
|
||||
const onNodeContextMenu = useCallback(
|
||||
(e: React.MouseEvent, node: Node<EntropykNodeData>) => {
|
||||
e.preventDefault();
|
||||
setSelected(node.id);
|
||||
setCtxMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
nodeId: node.id,
|
||||
nodeName: node.data.name,
|
||||
});
|
||||
},
|
||||
[setSelected],
|
||||
);
|
||||
|
||||
// Keep RF `selected` in sync, preserving node identity when the flag is unchanged
|
||||
// (`undefined` and `false` both mean unselected — avoid remapping every render).
|
||||
const displayNodes = useMemo(
|
||||
() =>
|
||||
nodes.map((n) => {
|
||||
const selected = n.id === selectedNodeId;
|
||||
if (!!n.selected === selected) return n;
|
||||
return { ...n, selected };
|
||||
}),
|
||||
[nodes, selectedNodeId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={styledEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange as OnNodesChange<Node<EntropykNodeData>>}
|
||||
onEdgesChange={onEdgesChange as OnEdgesChange<Edge>}
|
||||
onConnect={onConnect as OnConnect}
|
||||
onInit={onInit}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
isValidConnection={isValidConnection}
|
||||
onNodeClick={(_, node) => {
|
||||
setCtxMenu(null);
|
||||
setSelected(node.id);
|
||||
}}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onPaneClick={() => {
|
||||
setCtxMenu(null);
|
||||
setSelected(null);
|
||||
}}
|
||||
deleteKeyCode={["Delete", "Backspace"]}
|
||||
defaultEdgeOptions={{ type: "smoothstep" }}
|
||||
connectionRadius={34}
|
||||
snapToGrid={snapToGrid}
|
||||
snapGrid={[GRID_SIZE, GRID_SIZE]}
|
||||
minZoom={0.2}
|
||||
maxZoom={2.5}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
{/* Dymola-style diagram sheet: fine grid + coarse major lines. */}
|
||||
<Background
|
||||
id="minor"
|
||||
variant={BackgroundVariant.Lines}
|
||||
gap={GRID_SIZE}
|
||||
lineWidth={0.5}
|
||||
color="#eef2f7"
|
||||
/>
|
||||
<Background
|
||||
id="major"
|
||||
variant={BackgroundVariant.Lines}
|
||||
gap={GRID_SIZE * 5}
|
||||
lineWidth={0.8}
|
||||
color="#dde5ee"
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
<MediaLegend />
|
||||
{selectedNode && (
|
||||
<OrientationBar
|
||||
name={selectedNode.data.name}
|
||||
rotation={selectedNode.data.rotation ?? 0}
|
||||
flipH={!!selectedNode.data.flipH}
|
||||
flipV={!!selectedNode.data.flipV}
|
||||
onRotate={() => rotateNode(selectedNode.id, 1)}
|
||||
onFlipH={() => flipNodeH(selectedNode.id)}
|
||||
onFlipV={() => flipNodeV(selectedNode.id)}
|
||||
/>
|
||||
)}
|
||||
<NodeContextMenu
|
||||
menu={ctxMenu}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
onRotate={(dir) => ctxMenu && rotateNode(ctxMenu.nodeId, dir)}
|
||||
onFlipH={() => ctxMenu && flipNodeH(ctxMenu.nodeId)}
|
||||
onFlipV={() => ctxMenu && flipNodeV(ctxMenu.nodeId)}
|
||||
onDelete={() => ctxMenu && removeNode(ctxMenu.nodeId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrientationBar({
|
||||
name,
|
||||
rotation,
|
||||
flipH,
|
||||
flipV,
|
||||
onRotate,
|
||||
onFlipH,
|
||||
onFlipV,
|
||||
}: {
|
||||
name: string;
|
||||
rotation: number;
|
||||
flipH: boolean;
|
||||
flipV: boolean;
|
||||
onRotate: () => void;
|
||||
onFlipH: () => void;
|
||||
onFlipV: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute left-1/2 top-3 z-10 flex -translate-x-1/2 items-center gap-1 rounded-md border border-[var(--line)] bg-white/95 px-2 py-1 shadow-sm">
|
||||
<span className="mono mr-1 max-w-[100px] truncate text-[10px] text-[var(--ink-faint)]">
|
||||
{name} · {rotation}°
|
||||
{flipH ? " · ↔" : ""}
|
||||
{flipV ? " · ↕" : ""}
|
||||
</span>
|
||||
<button type="button" className="orient-btn" title="Rotate 90° (R)" onClick={onRotate}>
|
||||
<RotateCw size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orient-btn"
|
||||
data-on={flipH}
|
||||
title="Flip horizontal (H)"
|
||||
onClick={onFlipH}
|
||||
>
|
||||
<FlipHorizontal2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orient-btn"
|
||||
data-on={flipV}
|
||||
title="Flip vertical (V)"
|
||||
onClick={onFlipV}
|
||||
>
|
||||
<FlipVertical2 size={13} />
|
||||
</button>
|
||||
<style jsx>{`
|
||||
.orient-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 5px;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.orient-btn:hover {
|
||||
background: var(--chrome-2);
|
||||
color: var(--accent);
|
||||
}
|
||||
.orient-btn[data-on="true"] {
|
||||
background: rgba(27, 111, 224, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaLegend() {
|
||||
const items: MediaKind[] = ["refrigerant", "water", "air"];
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-3 left-3 z-10 flex items-center gap-3 rounded-md border border-[var(--line)] bg-white/95 px-2.5 py-1.5 shadow-sm"
|
||||
title="Couleurs milieu — convention TIL / Modelica HVAC"
|
||||
>
|
||||
<span className="eyebrow">Milieux</span>
|
||||
{items.map((k) => (
|
||||
<span key={k} className="flex items-center gap-1.5 text-[10px] text-[var(--ink-dim)]">
|
||||
<span
|
||||
className="inline-block h-0.5 w-4 rounded-full"
|
||||
style={{ background: MEDIA_COLOR[k] }}
|
||||
/>
|
||||
{MEDIA_LABEL[k]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/components/canvas/ComponentIcon.test.tsx
Normal file
26
apps/web/src/components/canvas/ComponentIcon.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { ComponentIcon } from "./ComponentIcon";
|
||||
import { COMPONENTS } from "@/lib/componentMeta";
|
||||
|
||||
describe("ComponentIcon", () => {
|
||||
it("renders an svg glyph for every catalogue component", () => {
|
||||
for (const c of COMPONENTS) {
|
||||
const { container, unmount } = render(<ComponentIcon type={c.type} color={c.color} />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg, `no svg for ${c.type}`).not.toBeNull();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the dashed placeholder for an unknown type", () => {
|
||||
const { container } = render(<ComponentIcon type="TotallyUnknown" color="#123456" />);
|
||||
const dashed = container.querySelector("[stroke-dasharray]");
|
||||
expect(dashed).not.toBeNull();
|
||||
});
|
||||
|
||||
it("tints the glyph with the provided colour", () => {
|
||||
const { container } = render(<ComponentIcon type="IsentropicCompressor" color="#abcdef" />);
|
||||
expect(container.innerHTML).toContain("#abcdef");
|
||||
});
|
||||
});
|
||||
323
apps/web/src/components/canvas/ComponentIcon.tsx
Normal file
323
apps/web/src/components/canvas/ComponentIcon.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Technical-drawing glyphs (ISO / P&ID linework).
|
||||
*
|
||||
* Design rules — one language for every part:
|
||||
* - structure drawn in a single ink, one stroke weight, butt caps
|
||||
* - no colour blobs: liquid = section hatch, valves/pumps = open triangles
|
||||
* - the `color` prop is a functional accent only (duty arrows, fluid detail)
|
||||
* - each HX family has a distinct, standards-inspired silhouette
|
||||
*/
|
||||
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { hxGlyphKey } from "@/lib/hxFamily";
|
||||
|
||||
type IconProps = { color: string };
|
||||
|
||||
const INK = "#2f3e52";
|
||||
const INK_FAINT = "rgba(47,62,82,0.34)";
|
||||
|
||||
function Frame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="none"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const line = (w = 2) => ({
|
||||
stroke: INK,
|
||||
strokeWidth: w,
|
||||
strokeLinecap: "butt" as const,
|
||||
strokeLinejoin: "miter" as const,
|
||||
});
|
||||
|
||||
const accent = (c: string, w = 2) => ({
|
||||
stroke: c,
|
||||
strokeWidth: w,
|
||||
strokeLinecap: "butt" as const,
|
||||
strokeLinejoin: "miter" as const,
|
||||
});
|
||||
|
||||
/** Section hatch — the engineering way to say "liquid here". */
|
||||
function Hatch({ xs, yBottom, yTop, run = 9 }: { xs: number[]; yBottom: number; yTop: number; run?: number }) {
|
||||
return (
|
||||
<g stroke={INK} strokeWidth={1}>
|
||||
{xs.map((x) => (
|
||||
<line key={x} x1={x} y1={yBottom} x2={x + run} y2={yTop} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** Up arrow (heat duty). */
|
||||
function DutyArrow({ x, y1, y2, c }: { x: number; y1: number; y2: number; c: string }) {
|
||||
const head = y2 < y1 ? 4 : -4;
|
||||
return (
|
||||
<g {...accent(c, 1.8)}>
|
||||
<line x1={x} y1={y1} x2={x} y2={y2} />
|
||||
<path d={`M${x - 3.2} ${y2 + head} L${x} ${y2} L${x + 3.2} ${y2 + head}`} fill="none" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const GLYPHS: Record<string, (c: string) => ReactElement> = {
|
||||
// ── Compressor — ISO circle + converging flow lines ────────
|
||||
compressor: (c) => (
|
||||
<Frame>
|
||||
<circle cx="50" cy="50" r="34" {...line(2.2)} />
|
||||
<path d="M28 30 L74 44 M28 70 L74 56" {...line(2)} />
|
||||
<path d="M74 44 V56" {...accent(c, 2.2)} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Shell & tube condenser — vessel, tubesheets, heat out ──
|
||||
hx_shell_cond: (c) => (
|
||||
<Frame>
|
||||
<rect x="8" y="34" width="84" height="36" rx="18" {...line(2.2)} />
|
||||
<line x1="22" y1="36" x2="22" y2="68" {...line(1.4)} />
|
||||
<line x1="78" y1="36" x2="78" y2="68" {...line(1.4)} />
|
||||
<path d="M22 44 H78 M22 52 H78 M22 60 H78" {...line(1.2)} />
|
||||
<DutyArrow x={35} y1={28} y2={14} c={c} />
|
||||
<DutyArrow x={50} y1={28} y2={12} c={c} />
|
||||
<DutyArrow x={65} y1={28} y2={14} c={c} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Shell & tube evaporator — heat in from below ───────────
|
||||
hx_shell_evap: (c) => (
|
||||
<Frame>
|
||||
<rect x="8" y="30" width="84" height="36" rx="18" {...line(2.2)} />
|
||||
<line x1="22" y1="32" x2="22" y2="64" {...line(1.4)} />
|
||||
<line x1="78" y1="32" x2="78" y2="64" {...line(1.4)} />
|
||||
<path d="M22 40 H78 M22 48 H78 M22 56 H78" {...line(1.2)} />
|
||||
<DutyArrow x={35} y1={88} y2={74} c={c} />
|
||||
<DutyArrow x={50} y1={90} y2={72} c={c} />
|
||||
<DutyArrow x={65} y1={88} y2={74} c={c} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Flooded evaporator — horizontal drum, level + hatch ────
|
||||
hx_flooded: (c) => (
|
||||
<Frame>
|
||||
{/* vapour outlet stub */}
|
||||
<path d="M46 20 V28 M54 20 V28" {...line(1.4)} />
|
||||
<rect x="10" y="28" width="80" height="44" rx="22" {...line(2.2)} />
|
||||
{/* liquid level */}
|
||||
<line x1="13" y1="42" x2="87" y2="42" {...accent(c, 1.6)} />
|
||||
{/* section hatch = liquid */}
|
||||
<Hatch xs={[20, 27, 34, 41, 48, 55, 62, 69, 76]} yBottom={66} yTop={46} />
|
||||
{/* submerged tube nest */}
|
||||
{[26, 38, 50, 62, 74].map((x) => (
|
||||
<circle key={`r1-${x}`} cx={x} cy="52" r="4" fill="#fff" {...line(1.3)} />
|
||||
))}
|
||||
{[32, 44, 56, 68].map((x) => (
|
||||
<circle key={`r2-${x}`} cx={x} cy="61" r="4" fill="#fff" {...line(1.3)} />
|
||||
))}
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Brazed plate — plate pack diagonals + 4 ports ──────────
|
||||
hx_plate: (c) => (
|
||||
<Frame>
|
||||
<rect x="30" y="10" width="40" height="80" {...line(2.2)} />
|
||||
<line x1="35" y1="10" x2="35" y2="90" {...line(1.2)} />
|
||||
<line x1="65" y1="10" x2="65" y2="90" {...line(1.2)} />
|
||||
{[32, 41, 50, 59, 68, 77, 86].map((y) => (
|
||||
<line key={y} x1="35" y1={y} x2="65" y2={y - 18} {...line(1.1)} />
|
||||
))}
|
||||
<circle cx="41" cy="19" r="3.4" fill="#fff" {...accent(c, 1.6)} />
|
||||
<circle cx="59" cy="19" r="3.4" fill="#fff" {...accent(c, 1.6)} />
|
||||
<circle cx="41" cy="81" r="3.4" fill="#fff" {...accent(c, 1.6)} />
|
||||
<circle cx="59" cy="81" r="3.4" fill="#fff" {...accent(c, 1.6)} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── DX coil — serpentine through fin pack ──────────────────
|
||||
hx_dx: (c) => (
|
||||
<Frame>
|
||||
<rect x="14" y="26" width="72" height="50" {...line(2.2)} />
|
||||
{[22, 30, 38, 46, 54, 62, 70, 78].map((x) => (
|
||||
<line key={x} x1={x} y1="26" x2={x} y2="76" stroke={INK_FAINT} strokeWidth={1} />
|
||||
))}
|
||||
<path
|
||||
d="M6 36 H72 a8 8 0 0 1 0 15 H28 a8 8 0 0 0 0 15 H94"
|
||||
{...accent(c, 2.2)}
|
||||
fill="none"
|
||||
/>
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Fin coil — radiator zigzag + air through ───────────────
|
||||
hx_coil: (c) => (
|
||||
<Frame>
|
||||
<rect x="12" y="28" width="76" height="48" {...line(2.2)} />
|
||||
<path
|
||||
d="M12 52 L21 36 L30 68 L39 36 L48 68 L57 36 L66 68 L75 36 L84 68 L88 52"
|
||||
{...line(1.6)}
|
||||
fill="none"
|
||||
/>
|
||||
<DutyArrow x={30} y1={92} y2={82} c={c} />
|
||||
<DutyArrow x={50} y1={92} y2={82} c={c} />
|
||||
<DutyArrow x={70} y1={92} y2={82} c={c} />
|
||||
<DutyArrow x={30} y1={22} y2={12} c={c} />
|
||||
<DutyArrow x={50} y1={22} y2={12} c={c} />
|
||||
<DutyArrow x={70} y1={22} y2={12} c={c} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── MCHX — headers + flat multiport tubes ──────────────────
|
||||
hx_mchx: (c) => (
|
||||
<Frame>
|
||||
<rect x="12" y="26" width="76" height="50" {...line(2.2)} />
|
||||
<line x1="21" y1="26" x2="21" y2="76" {...line(1.4)} />
|
||||
<line x1="79" y1="26" x2="79" y2="76" {...line(1.4)} />
|
||||
{[33, 40, 47, 54, 61, 68].map((y) => (
|
||||
<line key={y} x1="21" y1={y} x2="79" y2={y} {...line(1.1)} />
|
||||
))}
|
||||
<path d="M6 32 H12 M88 32 H94" {...accent(c, 2)} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Generic HX — ISO circle with wavy element ──────────────
|
||||
hx: (c) => (
|
||||
<Frame>
|
||||
<circle cx="50" cy="50" r="32" {...line(2.2)} />
|
||||
<path d="M18 50 C28 36 39 64 50 50 S 72 36 82 50" {...accent(c, 2)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
condenser: (c) => GLYPHS.hx_shell_cond(c),
|
||||
evaporator: (c) => GLYPHS.hx_dx(c),
|
||||
|
||||
// ── Valve — ISO bowtie + actuator stem ─────────────────────
|
||||
valve: (c) => (
|
||||
<Frame>
|
||||
<path d="M12 32 L12 68 L50 50 Z" {...line(2)} fill="none" />
|
||||
<path d="M88 32 L88 68 L50 50 Z" {...line(2)} fill="none" />
|
||||
<line x1="50" y1="50" x2="50" y2="28" {...line(1.6)} />
|
||||
<line x1="38" y1="28" x2="62" y2="28" {...accent(c, 2.2)} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Pump — circle + filled flow triangle ───────────────────
|
||||
pump: (c) => (
|
||||
<Frame>
|
||||
<circle cx="50" cy="50" r="32" {...line(2.2)} />
|
||||
<path d="M38 32 L70 50 L38 68 Z" fill={c} stroke="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Fan — circle + two blades ──────────────────────────────
|
||||
fan: (c) => (
|
||||
<Frame>
|
||||
<circle cx="50" cy="50" r="32" {...line(2.2)} />
|
||||
<path d="M50 50 C36 42 38 24 56 28 M50 50 C64 58 62 76 44 72" {...accent(c, 2.2)} fill="none" />
|
||||
<circle cx="50" cy="50" r="3" fill={INK} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Pipe — double line + flanges ───────────────────────────
|
||||
pipe: (c) => (
|
||||
<Frame>
|
||||
<path d="M6 44 H94 M6 56 H94" {...line(1.8)} />
|
||||
<path d="M14 38 V62 M86 38 V62" {...line(1.6)} />
|
||||
<path d="M42 50 H58 M53 45 L58 50 L53 55" {...accent(c, 1.6)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Separator drum — vertical vessel, level + hatch ────────
|
||||
drum: (c) => (
|
||||
<Frame>
|
||||
<rect x="30" y="8" width="40" height="84" rx="20" {...line(2.2)} />
|
||||
<line x1="32" y1="56" x2="68" y2="56" {...accent(c, 1.6)} />
|
||||
<Hatch xs={[36, 44, 52]} yBottom={82} yTop={62} run={8} />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
splitter: (c) => (
|
||||
<Frame>
|
||||
<path d="M8 50 H42 M42 50 L88 26 M42 50 L88 74" {...line(2.2)} />
|
||||
<circle cx="42" cy="50" r="4" fill={INK} />
|
||||
<path d="M80 30 L88 26 L86 35" {...accent(c, 1.6)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
merger: (c) => (
|
||||
<Frame>
|
||||
<path d="M12 26 L58 50 M12 74 L58 50 M58 50 H92" {...line(2.2)} />
|
||||
<circle cx="58" cy="50" r="4" fill={INK} />
|
||||
<path d="M84 45 L92 50 L84 55" {...accent(c, 1.6)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Boundary source — circle + hatched ground + arrow ──────
|
||||
source: (c) => (
|
||||
<Frame>
|
||||
<circle cx="40" cy="50" r="24" {...line(2.2)} />
|
||||
<Hatch xs={[26, 33, 40]} yBottom={62} yTop={40} run={8} />
|
||||
<path d="M66 50 H92 M84 42 L92 50 L84 58" {...accent(c, 2.2)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
sink: (c) => (
|
||||
<Frame>
|
||||
<circle cx="60" cy="50" r="24" {...line(2.2)} />
|
||||
<Hatch xs={[46, 53, 60]} yBottom={62} yTop={40} run={8} />
|
||||
<path d="M8 50 H34 M26 42 L34 50 L26 58" {...accent(c, 2.2)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
placeholder: (c) => (
|
||||
<Frame>
|
||||
<rect
|
||||
x="14"
|
||||
y="24"
|
||||
width="72"
|
||||
height="52"
|
||||
stroke={c}
|
||||
strokeWidth={1.8}
|
||||
strokeDasharray="5 4"
|
||||
/>
|
||||
</Frame>
|
||||
),
|
||||
|
||||
control: (c) => (
|
||||
<Frame>
|
||||
<rect x="8" y="24" width="84" height="52" {...line(2)} />
|
||||
<path d="M16 60 C26 34 38 34 48 60 S 70 78 84 52" {...accent(c, 2)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
};
|
||||
|
||||
function glyphKey(type: string): keyof typeof GLYPHS {
|
||||
const hx = hxGlyphKey(type);
|
||||
if (hx && hx in GLYPHS) return hx;
|
||||
|
||||
if (type === "SaturatedController") return "control";
|
||||
if (type.includes("Compressor")) return "compressor";
|
||||
if (type === "HeatExchanger") return "hx";
|
||||
if (type.includes("Valve")) return "valve";
|
||||
if (type === "Pump") return "pump";
|
||||
if (type === "Fan") return "fan";
|
||||
if (type === "Pipe" || type.includes("Pipe") || type === "AirDuct") return "pipe";
|
||||
if (type === "Drum") return "drum";
|
||||
if (type === "FlowSplitter") return "splitter";
|
||||
if (type === "FlowMerger") return "merger";
|
||||
if (type.endsWith("Source")) return "source";
|
||||
if (type.endsWith("Sink")) return "sink";
|
||||
return "placeholder";
|
||||
}
|
||||
|
||||
export function ComponentIcon({ type, color }: { type: string } & IconProps) {
|
||||
const draw = GLYPHS[glyphKey(type)] ?? GLYPHS.placeholder;
|
||||
return draw(color);
|
||||
}
|
||||
224
apps/web/src/components/canvas/EntropykNode.tsx
Normal file
224
apps/web/src/components/canvas/EntropykNode.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { COMPONENT_BY_TYPE, portSide, portRole, portLabel, nodeSize } from "@/lib/componentMeta";
|
||||
import { mediaColor, mediaForPort } from "@/lib/mediaStyle";
|
||||
import { effectiveSide, type Side } from "@/lib/orientation";
|
||||
import type { EntropykNodeData } from "@/store/diagramStore";
|
||||
import { hxFamily } from "@/lib/hxFamily";
|
||||
import { ComponentIcon } from "./ComponentIcon";
|
||||
|
||||
const SIDE_POSITION: Record<Side, Position> = {
|
||||
left: Position.Left,
|
||||
right: Position.Right,
|
||||
top: Position.Top,
|
||||
bottom: Position.Bottom,
|
||||
};
|
||||
|
||||
function EntropykNode({ data, selected }: NodeProps) {
|
||||
const d = data as unknown as EntropykNodeData;
|
||||
const meta = COMPONENT_BY_TYPE[d.type];
|
||||
const { w: BOX_W, h: BOX_H } = nodeSize(d.type);
|
||||
const rotation = d.rotation ?? 0;
|
||||
const flipH = !!d.flipH;
|
||||
const flipV = !!d.flipV;
|
||||
const isController = d.type === "SaturatedController";
|
||||
const family = hxFamily(d.type);
|
||||
|
||||
if (!meta) {
|
||||
return (
|
||||
<div className="rounded border border-[var(--hot)] bg-white px-2 py-1 text-[11px] text-[var(--hot)]">
|
||||
Unknown: {d.type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const accent = family?.accent ?? meta.color;
|
||||
|
||||
const placement: Record<string, { side: Side; offset: number }> = {};
|
||||
for (const port of meta.ports) {
|
||||
const side = effectiveSide(portSide(port) as Side, rotation, flipH, flipV);
|
||||
const peers = meta.ports.filter(
|
||||
(p) => effectiveSide(portSide(p) as Side, rotation, flipH, flipV) === side,
|
||||
);
|
||||
const idx = peers.indexOf(port);
|
||||
placement[port] = {
|
||||
side,
|
||||
offset: peers.length === 1 ? 0.5 : (idx + 1) / (peers.length + 1),
|
||||
};
|
||||
}
|
||||
|
||||
const iconTransform = [
|
||||
rotation ? `rotate(${rotation}deg)` : "",
|
||||
flipH ? "scaleX(-1)" : "",
|
||||
flipV ? "scaleY(-1)" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" style={{ width: BOX_W }}>
|
||||
{/* Symbol IS the node — selection is a CAD marquee, not a card */}
|
||||
<div
|
||||
className="ek-node relative"
|
||||
style={{
|
||||
width: BOX_W,
|
||||
height: BOX_H,
|
||||
outline: selected ? "1.5px dashed var(--accent)" : "1.5px dashed transparent",
|
||||
outlineOffset: 4,
|
||||
}}
|
||||
>
|
||||
{isController ? (
|
||||
<div className="h-full w-full overflow-hidden rounded-[3px] border border-[var(--line-strong)] bg-white">
|
||||
<ControlNodeFace data={d} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{ transform: iconTransform || undefined }}
|
||||
>
|
||||
<ComponentIcon type={d.type} color={accent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.ports.map((port) => {
|
||||
const { side, offset } = placement[port];
|
||||
const horizontal = side === "left" || side === "right";
|
||||
const kind = mediaForPort({ data: d }, port);
|
||||
const c = mediaColor(kind);
|
||||
const style: React.CSSProperties = {
|
||||
...(horizontal ? { top: `${offset * 100}%` } : { left: `${offset * 100}%` }),
|
||||
borderColor: c,
|
||||
background: c,
|
||||
};
|
||||
return (
|
||||
<Handle
|
||||
key={port}
|
||||
type={portRole(port)}
|
||||
position={SIDE_POSITION[side]}
|
||||
id={port}
|
||||
style={style}
|
||||
isConnectable
|
||||
title={`${port} · ${kind}`}
|
||||
className="media-handle"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Port tags sit INSIDE the symbol bounds — never collide with names */}
|
||||
{meta.ports.length > 2 &&
|
||||
meta.ports.map((port) => {
|
||||
const { side, offset } = placement[port];
|
||||
const horizontal = side === "left" || side === "right";
|
||||
const style: React.CSSProperties = horizontal
|
||||
? { top: `${offset * 100}%`, transform: "translateY(-50%)", [side]: 8 }
|
||||
: { left: `${offset * 100}%`, transform: "translateX(-50%)", [side]: 7 };
|
||||
return (
|
||||
<span
|
||||
key={`t-${port}`}
|
||||
className="mono pointer-events-none absolute text-[6.5px] uppercase tracking-wide text-[var(--ink-faint)]"
|
||||
style={style}
|
||||
>
|
||||
{portLabel(port)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex max-w-[200px] flex-col items-center leading-tight">
|
||||
{!isController && (
|
||||
<span className="mono truncate text-[10.5px] text-[var(--ink)]">{d.name}</span>
|
||||
)}
|
||||
<span className="truncate text-[8.5px] text-[var(--ink-faint)]">
|
||||
{isController ? "override control" : meta.label} · C{d.circuit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlNodeFace({ data }: { data: EntropykNodeData }) {
|
||||
const p = data.params;
|
||||
const measure = `${String(p.measure_component ?? "—")}.${shortOutput(String(p.measure_output ?? "—"))}`;
|
||||
const actuator = `${String(p.actuator_component ?? "—")}.${String(p.actuator_factor ?? "—")}`;
|
||||
const target = formatControlNumber(p.target);
|
||||
const bounds = `${formatControlNumber(p.min)}…${formatControlNumber(p.max)}`;
|
||||
const objectiveCount = controlObjectiveCount(p.objectives_json);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden text-left">
|
||||
<div className="flex items-center justify-between bg-[#4c1d95] px-2 py-1 text-white">
|
||||
<span className="mono max-w-[122px] truncate text-[10px] font-semibold">{data.name}</span>
|
||||
<span className="rounded bg-white/20 px-1 py-0.5 text-[7px] font-bold uppercase tracking-wide">
|
||||
SS
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid flex-1 grid-cols-[1fr_44px_1fr] items-stretch bg-[#f5f3ff]">
|
||||
<div className="flex min-w-0 flex-col justify-center px-2">
|
||||
<span className="text-[7px] font-semibold uppercase tracking-[0.08em] text-[#6b5b8c]">
|
||||
Controlled
|
||||
</span>
|
||||
<span className="mono truncate text-[9px] font-semibold text-[#2e1f4a]">{measure}</span>
|
||||
<span className="mono text-[8px] text-[#6b5b8c]">SP {target}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center border-x border-[#ddd6fe] bg-[#ede9fe]">
|
||||
<span className="text-[7px] font-bold uppercase text-[#5b21b6]">Select</span>
|
||||
<span className="mono mt-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-[#5b21b6] px-1 text-[9px] font-bold text-white">
|
||||
{objectiveCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col justify-center px-2">
|
||||
<span className="text-[7px] font-semibold uppercase tracking-[0.08em] text-[#6b5b8c]">
|
||||
Actuator
|
||||
</span>
|
||||
<span className="mono truncate text-[9px] font-semibold text-[#2e1f4a]">{actuator}</span>
|
||||
<span className="mono text-[8px] text-[#6b5b8c]">{bounds}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function controlObjectiveCount(value: number | string | boolean | undefined): number {
|
||||
if (typeof value !== "string") return 0;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function shortOutput(output: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
saturationTemperature: "Tsat",
|
||||
heatTransferRate: "Qdot",
|
||||
massFlowRate: "mdot",
|
||||
temperature: "T",
|
||||
pressure: "P",
|
||||
capacity: "Q",
|
||||
superheat: "SH",
|
||||
subcooling: "SC",
|
||||
};
|
||||
return labels[output] ?? output;
|
||||
}
|
||||
|
||||
function formatControlNumber(value: number | string | boolean | undefined): string {
|
||||
return typeof value === "number" && Number.isFinite(value) ? Number(value.toPrecision(4)).toString() : "—";
|
||||
}
|
||||
|
||||
export default memo(EntropykNode, (prev, next) => {
|
||||
const a = prev.data as unknown as EntropykNodeData;
|
||||
const b = next.data as unknown as EntropykNodeData;
|
||||
return (
|
||||
prev.selected === next.selected &&
|
||||
a.type === b.type &&
|
||||
a.name === b.name &&
|
||||
a.circuit === b.circuit &&
|
||||
(a.rotation ?? 0) === (b.rotation ?? 0) &&
|
||||
!!a.flipH === !!b.flipH &&
|
||||
!!a.flipV === !!b.flipV &&
|
||||
a.params === b.params
|
||||
);
|
||||
});
|
||||
90
apps/web/src/components/canvas/NodeContextMenu.tsx
Normal file
90
apps/web/src/components/canvas/NodeContextMenu.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { FlipHorizontal2, FlipVertical2, RotateCcw, RotateCw, Trash2 } from "lucide-react";
|
||||
|
||||
export interface NodeContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
menu: NodeContextMenuState | null;
|
||||
onClose: () => void;
|
||||
onRotate: (dir: 1 | -1) => void;
|
||||
onFlipH: () => void;
|
||||
onFlipV: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/** Right-click menu — Modelica-style orientation + delete. */
|
||||
export default function NodeContextMenu({
|
||||
menu,
|
||||
onClose,
|
||||
onRotate,
|
||||
onFlipH,
|
||||
onFlipV,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as HTMLElement)) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("mousedown", onDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("mousedown", onDown);
|
||||
};
|
||||
}, [menu, onClose]);
|
||||
|
||||
if (!menu) return null;
|
||||
|
||||
const item = (
|
||||
label: string,
|
||||
shortcut: string,
|
||||
icon: React.ReactNode,
|
||||
action: () => void,
|
||||
) => (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-[var(--ink)] hover:bg-[var(--chrome-2)]"
|
||||
onClick={() => {
|
||||
action();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="text-[var(--ink-faint)]">{icon}</span>
|
||||
<span className="flex-1">{label}</span>
|
||||
<span className="mono text-[10px] text-[var(--ink-faint)]">{shortcut}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-50 min-w-[200px] overflow-hidden rounded-md border border-[var(--line)] bg-white py-1 shadow-lg"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
role="menu"
|
||||
>
|
||||
<div className="border-b border-[var(--line)] px-3 py-1.5">
|
||||
<div className="mono truncate text-[11px] font-semibold text-[var(--ink)]">{menu.nodeName}</div>
|
||||
<div className="text-[10px] text-[var(--ink-faint)]">Orientation (Modelica)</div>
|
||||
</div>
|
||||
{item("Rotate 90° CW", "R", <RotateCw size={14} />, () => onRotate(1))}
|
||||
{item("Rotate 90° CCW", "Shift+R", <RotateCcw size={14} />, () => onRotate(-1))}
|
||||
{item("Flip horizontal", "H", <FlipHorizontal2 size={14} />, onFlipH)}
|
||||
{item("Flip vertical", "V", <FlipVertical2 size={14} />, onFlipV)}
|
||||
<div className="my-1 h-px bg-[var(--line)]" />
|
||||
{item("Delete", "Del", <Trash2 size={14} />, onDelete)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
apps/web/src/components/palette/ComponentPalette.tsx
Normal file
118
apps/web/src/components/palette/ComponentPalette.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { COMPONENTS, COMPONENT_CATEGORIES, type ComponentMeta } from "@/lib/componentMeta";
|
||||
import { ComponentIcon } from "@/components/canvas/ComponentIcon";
|
||||
import { hxFamily } from "@/lib/hxFamily";
|
||||
|
||||
export default function ComponentPalette() {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({
|
||||
Advanced: true,
|
||||
Generic: true,
|
||||
});
|
||||
const toggle = (cat: string) => setCollapsed((c) => ({ ...c, [cat]: !c[cat] }));
|
||||
|
||||
const onDragStart = (e: React.DragEvent, type: string) => {
|
||||
e.dataTransfer.setData("application/entropyk-type", type);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-60 flex-col border-r border-[var(--line)] bg-[var(--panel)]">
|
||||
<div className="flex h-9 items-center justify-between px-3">
|
||||
<span className="eyebrow">Bibliothèque</span>
|
||||
<span className="mono text-[10px] text-[var(--ink-faint)]">{COMPONENTS.length}</span>
|
||||
</div>
|
||||
<div className="h-px w-full bg-[var(--line)]" />
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{COMPONENT_CATEGORIES.map((cat) => {
|
||||
const items = COMPONENTS.filter((c) => c.category === cat);
|
||||
if (items.length === 0) return null;
|
||||
const isOpen = !collapsed[cat];
|
||||
return (
|
||||
<div key={cat} className="palette-cat">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(cat)}
|
||||
aria-expanded={isOpen}
|
||||
className="palette-cat-toggle flex w-full items-center gap-1 px-2.5 py-1.5 text-left hover:bg-[var(--chrome-2)]"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={12} className="text-[var(--ink-faint)]" />
|
||||
) : (
|
||||
<ChevronRight size={12} className="text-[var(--ink-faint)]" />
|
||||
)}
|
||||
<span className="eyebrow">{cat}</span>
|
||||
<span className="mono ml-1 text-[9px] text-[var(--ink-faint)]">{items.length}</span>
|
||||
{cat === "Advanced" && (
|
||||
<span className="ml-auto text-[8px] font-medium text-[var(--ink-faint)]">
|
||||
rare
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className={`palette-cat-body ${isOpen ? "is-open" : "is-closed"}`}>
|
||||
<div className="palette-cat-inner pb-1">
|
||||
{items.map((c, i) => (
|
||||
<PaletteItem
|
||||
key={c.type}
|
||||
meta={c}
|
||||
index={i}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-px w-full bg-[var(--line)]" />
|
||||
<p className="px-3 py-2 text-[10px] leading-relaxed text-[var(--ink-faint)]">
|
||||
Glisse un composant sur le schéma. Relie les ports. Un pipe/gaine déposé sur une ligne
|
||||
s’insère automatiquement (même milieu : vert / bleu / jaune).
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteItem({
|
||||
meta,
|
||||
index,
|
||||
onDragStart,
|
||||
}: {
|
||||
meta: ComponentMeta;
|
||||
index: number;
|
||||
onDragStart: (e: React.DragEvent, type: string) => void;
|
||||
}) {
|
||||
const family = hxFamily(meta.type);
|
||||
const color = family?.accent ?? meta.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, meta.type)}
|
||||
className="palette-item group mx-1.5 flex cursor-grab items-center gap-2.5 rounded-[2px] px-2 py-1.5 hover:bg-[var(--chrome-2)] active:cursor-grabbing"
|
||||
style={{ ["--i" as string]: index }}
|
||||
title={meta.help ?? meta.description}
|
||||
>
|
||||
<span className="grid h-9 w-9 flex-shrink-0 place-items-center">
|
||||
<span style={{ width: 30, height: 30 }}>
|
||||
<ComponentIcon type={meta.type} color={color} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] text-[var(--ink-dim)] group-hover:text-[var(--ink)]">
|
||||
{meta.label}
|
||||
</span>
|
||||
{family && (
|
||||
<span className="mono text-[8px] uppercase tracking-[0.08em] text-[var(--ink-faint)]">
|
||||
{family.badge} · {family.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
apps/web/src/components/panels/ComponentDocPanel.tsx
Normal file
141
apps/web/src/components/panels/ComponentDocPanel.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { docSlugForType, docUrlForType } from "@/lib/componentDocMap";
|
||||
import MarkdownDoc from "./MarkdownDoc";
|
||||
|
||||
type Lang = "FR" | "EN" | "ALL";
|
||||
|
||||
/**
|
||||
* Extract ## EN / ## FR sections from bilingual component docs.
|
||||
* Keeps tables and code fences intact (no aggressive trimming of structure).
|
||||
*/
|
||||
function extractLang(md: string, lang: Lang): string {
|
||||
if (lang === "ALL") return md;
|
||||
const title = titleFrom(md);
|
||||
const normalized = md.replace(/\r\n/g, "\n");
|
||||
|
||||
const enIdx = normalized.search(/^## EN\s*$/m);
|
||||
const frIdx = normalized.search(/^## FR\s*$/m);
|
||||
|
||||
if (lang === "EN" && enIdx >= 0) {
|
||||
const end = frIdx > enIdx ? frIdx : normalized.length;
|
||||
const body = normalized.slice(enIdx, end).replace(/^## EN\s*\n?/, "").trimEnd();
|
||||
return `# ${title}\n\n${body}\n`;
|
||||
}
|
||||
if (lang === "FR" && frIdx >= 0) {
|
||||
const body = normalized.slice(frIdx).replace(/^## FR\s*\n?/, "").trimEnd();
|
||||
return `# ${title}\n\n${body}\n`;
|
||||
}
|
||||
// Fallback: full document
|
||||
return md;
|
||||
}
|
||||
|
||||
function titleFrom(md: string): string {
|
||||
const m = /^#\s+(.+)$/m.exec(md);
|
||||
return m ? m[1].trim() : "Documentation";
|
||||
}
|
||||
|
||||
export default function ComponentDocPanel({
|
||||
componentType,
|
||||
fallbackHelp,
|
||||
}: {
|
||||
componentType: string;
|
||||
fallbackHelp?: string;
|
||||
}) {
|
||||
const [raw, setRaw] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lang, setLang] = useState<Lang>("FR");
|
||||
|
||||
const slug = docSlugForType(componentType);
|
||||
const url = docUrlForType(componentType);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
setRaw(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`Doc introuvable (${res.status})`);
|
||||
return res.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (!cancelled) {
|
||||
setRaw(text);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!cancelled) {
|
||||
setRaw(null);
|
||||
setError(e.message);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<div className="px-2.5 py-2 text-[10px] text-[var(--ink-faint)]">
|
||||
{fallbackHelp ? (
|
||||
<p className="whitespace-pre-line text-[var(--ink-dim)]">{fallbackHelp}</p>
|
||||
) : (
|
||||
<p>Pas de fiche technique liée à ce type.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[min(42vh,360px)] flex-col">
|
||||
<div className="flex items-center justify-end gap-1 border-b border-[var(--line)] bg-[var(--chrome-2)] px-2 py-1">
|
||||
{(["FR", "EN", "ALL"] as Lang[]).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => setLang(l)}
|
||||
className={`rounded px-1.5 py-0.5 text-[9px] font-semibold ${
|
||||
lang === l
|
||||
? "bg-white text-[var(--ink)] shadow-sm"
|
||||
: "text-[var(--ink-faint)] hover:text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-1 text-[9px] text-[var(--accent)] underline"
|
||||
title="Ouvrir le fichier markdown"
|
||||
>
|
||||
.md
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2.5 py-2">
|
||||
{loading && <p className="text-[10px] text-[var(--ink-faint)]">Chargement…</p>}
|
||||
{error && (
|
||||
<div className="text-[10px] text-[var(--hot)]">
|
||||
<p>{error}</p>
|
||||
{fallbackHelp && (
|
||||
<p className="mt-2 whitespace-pre-line text-[var(--ink-dim)]">{fallbackHelp}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && raw && <MarkdownDoc source={extractLang(raw, lang)} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
373
apps/web/src/components/panels/MarkdownDoc.tsx
Normal file
373
apps/web/src/components/panels/MarkdownDoc.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Lightweight markdown renderer for component technical docs.
|
||||
* Robust tables (GFM), fenced code, lists, headings.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
type Block =
|
||||
| { kind: "h"; level: number; text: string }
|
||||
| { kind: "p"; text: string }
|
||||
| { kind: "code"; text: string }
|
||||
| { kind: "ul"; items: string[] }
|
||||
| { kind: "ol"; items: string[] }
|
||||
| { kind: "table"; headers: string[]; rows: string[][] }
|
||||
| { kind: "hr" }
|
||||
| { kind: "blockquote"; text: string };
|
||||
|
||||
function isSepLine(line: string): boolean {
|
||||
const t = line.trim();
|
||||
if (!t.includes("-") && !t.includes(":")) return false;
|
||||
// GFM separator: | --- | :---: | ---: |
|
||||
return /^\|?[\s|:\-]+$/.test(t) && /-+/.test(t);
|
||||
}
|
||||
|
||||
function splitCells(row: string): string[] {
|
||||
let r = row.trim();
|
||||
if (r.startsWith("|")) r = r.slice(1);
|
||||
if (r.endsWith("|")) r = r.slice(0, -1);
|
||||
return r.split("|").map((c) => c.trim());
|
||||
}
|
||||
|
||||
function parseBlocks(md: string): Block[] {
|
||||
const lines = md.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const blocks: Block[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith("```")) {
|
||||
const body: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !lines[i].startsWith("```")) {
|
||||
body.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
if (i < lines.length) i += 1;
|
||||
blocks.push({ kind: "code", text: body.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hm = /^(#{1,4})\s+(.+)$/.exec(line);
|
||||
if (hm) {
|
||||
blocks.push({ kind: "h", level: hm[1].length, text: hm[2].trim() });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^---+$/.test(line.trim()) || /^\*\*\*+$/.test(line.trim())) {
|
||||
blocks.push({ kind: "hr" });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(">")) {
|
||||
const parts: string[] = [];
|
||||
while (i < lines.length && lines[i].startsWith(">")) {
|
||||
parts.push(lines[i].replace(/^>\s?/, ""));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ kind: "blockquote", text: parts.join(" ") });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table: header line with | and next line is separator
|
||||
if (line.includes("|") && i + 1 < lines.length && isSepLine(lines[i + 1])) {
|
||||
const headers = splitCells(line);
|
||||
i += 2;
|
||||
const rows: string[][] = [];
|
||||
while (i < lines.length && lines[i].includes("|") && lines[i].trim() !== "" && !isSepLine(lines[i])) {
|
||||
const cells = splitCells(lines[i]);
|
||||
// pad / trim to header width
|
||||
while (cells.length < headers.length) cells.push("");
|
||||
rows.push(cells.slice(0, headers.length));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ kind: "table", headers, rows });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*[-*]\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\s*[-*]\s+/, ""));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ kind: "ul", items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*\d+\.\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\s*\d+\.\s+/, ""));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ kind: "ol", items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === "") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// paragraph — stop before tables/lists/headings/code
|
||||
const parts: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const L = lines[i];
|
||||
if (L.trim() === "") break;
|
||||
if (L.startsWith("#") || L.startsWith("```") || L.startsWith(">")) break;
|
||||
if (/^\s*[-*]\s+/.test(L) || /^\s*\d+\.\s+/.test(L)) break;
|
||||
if (L.includes("|") && i + 1 < lines.length && isSepLine(lines[i + 1])) break;
|
||||
if (isSepLine(L)) break;
|
||||
parts.push(L.trim());
|
||||
i += 1;
|
||||
}
|
||||
if (parts.length) blocks.push({ kind: "p", text: parts.join(" ") });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function Inline({ text }: { text: string }) {
|
||||
const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\([^)]+\))/g);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, idx) => {
|
||||
if (!part) return null;
|
||||
if (part.startsWith("`") && part.endsWith("`") && part.length >= 2) {
|
||||
return (
|
||||
<code key={idx} className="doc-code-inline">
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
if (part.startsWith("**") && part.endsWith("**") && part.length >= 4) {
|
||||
return <strong key={idx}>{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
const lm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part);
|
||||
if (lm) {
|
||||
return (
|
||||
<a key={idx} href={lm[2]} className="doc-link" target="_blank" rel="noreferrer">
|
||||
{lm[1]}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <span key={idx}>{part}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MarkdownDoc({ source }: { source: string }) {
|
||||
const blocks = useMemo(() => parseBlocks(source), [source]);
|
||||
|
||||
return (
|
||||
<div className="doc-md">
|
||||
{blocks.map((b, i) => {
|
||||
switch (b.kind) {
|
||||
case "h":
|
||||
if (b.level <= 1)
|
||||
return (
|
||||
<h1 key={i} className="doc-h1">
|
||||
<Inline text={b.text} />
|
||||
</h1>
|
||||
);
|
||||
if (b.level === 2)
|
||||
return (
|
||||
<h2 key={i} className="doc-h2">
|
||||
<Inline text={b.text} />
|
||||
</h2>
|
||||
);
|
||||
if (b.level === 3)
|
||||
return (
|
||||
<h3 key={i} className="doc-h3">
|
||||
<Inline text={b.text} />
|
||||
</h3>
|
||||
);
|
||||
return (
|
||||
<h4 key={i} className="doc-h4">
|
||||
<Inline text={b.text} />
|
||||
</h4>
|
||||
);
|
||||
case "p":
|
||||
return (
|
||||
<p key={i} className="doc-p">
|
||||
<Inline text={b.text} />
|
||||
</p>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<pre key={i} className="doc-pre">
|
||||
<code>{b.text}</code>
|
||||
</pre>
|
||||
);
|
||||
case "ul":
|
||||
return (
|
||||
<ul key={i} className="doc-ul">
|
||||
{b.items.map((it, j) => (
|
||||
<li key={j}>
|
||||
<Inline text={it} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
case "ol":
|
||||
return (
|
||||
<ol key={i} className="doc-ol">
|
||||
{b.items.map((it, j) => (
|
||||
<li key={j}>
|
||||
<Inline text={it} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
case "table":
|
||||
return (
|
||||
<div key={i} className="doc-table-wrap">
|
||||
<table className="doc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{b.headers.map((h, j) => (
|
||||
<th key={j}>
|
||||
<Inline text={h} />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{b.rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci}>
|
||||
<Inline text={cell} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
case "hr":
|
||||
return <hr key={i} className="doc-hr" />;
|
||||
case "blockquote":
|
||||
return (
|
||||
<blockquote key={i} className="doc-quote">
|
||||
<Inline text={b.text} />
|
||||
</blockquote>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
<style jsx>{`
|
||||
:global(.doc-md) {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
:global(.doc-h1) {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin: 0.4em 0 0.3em;
|
||||
}
|
||||
:global(.doc-h2) {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin: 0.75em 0 0.3em;
|
||||
padding-bottom: 0.15em;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
:global(.doc-h3) {
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
color: var(--ink);
|
||||
margin: 0.55em 0 0.2em;
|
||||
}
|
||||
:global(.doc-h4) {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin: 0.45em 0 0.15em;
|
||||
}
|
||||
:global(.doc-p) {
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
:global(.doc-pre) {
|
||||
margin: 0.35em 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
:global(.doc-code-inline) {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
background: var(--chrome-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
color: var(--ink);
|
||||
}
|
||||
:global(.doc-ul),
|
||||
:global(.doc-ol) {
|
||||
margin: 0.3em 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
:global(.doc-table-wrap) {
|
||||
overflow-x: auto;
|
||||
margin: 0.4em 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
:global(.doc-table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
table-layout: auto;
|
||||
}
|
||||
:global(.doc-table th),
|
||||
:global(.doc-table td) {
|
||||
border: 1px solid var(--line);
|
||||
padding: 4px 6px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
}
|
||||
:global(.doc-table th) {
|
||||
background: var(--chrome-2);
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
:global(.doc-hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
:global(.doc-quote) {
|
||||
margin: 0.35em 0;
|
||||
padding: 6px 8px;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--chrome-2);
|
||||
}
|
||||
:global(.doc-link) {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
374
apps/web/src/components/panels/MultiRunPanel.tsx
Normal file
374
apps/web/src/components/panels/MultiRunPanel.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Layers, Loader2, Play, Plus, Trash2, X } from "lucide-react";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
import { buildScenarioConfig, validateConfig } from "@/lib/configBuilder";
|
||||
import type { EntropykNodeData } from "@/lib/configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import {
|
||||
buildSweepCases,
|
||||
defaultSweepsFromDiagram,
|
||||
discoverSweepTargets,
|
||||
extractKpis,
|
||||
runParallel,
|
||||
suggestValues,
|
||||
type MultiRunResult,
|
||||
type SweepSpec,
|
||||
type SweepTarget,
|
||||
} from "@/lib/multiRun";
|
||||
|
||||
const GROUP_LABEL: Record<SweepTarget["group"], string> = {
|
||||
boundaries: "Conditions limites (eau / air)",
|
||||
thermal: "Échangeurs (UA…)",
|
||||
machine: "Machine (compresseur / détendeur)",
|
||||
global: "Global",
|
||||
};
|
||||
|
||||
export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
fluid,
|
||||
fluidBackend,
|
||||
solverStrategy,
|
||||
maxIterations,
|
||||
tolerance,
|
||||
} = useDiagramStore();
|
||||
|
||||
const diagramNodes = nodes as Node<EntropykNodeData>[];
|
||||
const targets = useMemo(
|
||||
() => discoverSweepTargets(diagramNodes, fluid),
|
||||
[diagramNodes, fluid],
|
||||
);
|
||||
|
||||
const [sweeps, setSweeps] = useState<SweepSpec[]>(() =>
|
||||
defaultSweepsFromDiagram(diagramNodes, fluid),
|
||||
);
|
||||
const [concurrency, setConcurrency] = useState(4);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState({ done: 0, total: 0 });
|
||||
const [results, setResults] = useState<MultiRunResult[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const baseConfig = useMemo(
|
||||
() =>
|
||||
buildScenarioConfig(diagramNodes, edges, {
|
||||
fluid,
|
||||
fluidBackend,
|
||||
solverStrategy,
|
||||
maxIterations,
|
||||
tolerance,
|
||||
}),
|
||||
[diagramNodes, edges, fluid, fluidBackend, solverStrategy, maxIterations, tolerance],
|
||||
);
|
||||
|
||||
const cases = useMemo(() => buildSweepCases(baseConfig, sweeps), [baseConfig, sweeps]);
|
||||
|
||||
const targetsByGroup = useMemo(() => {
|
||||
const map = new Map<SweepTarget["group"], SweepTarget[]>();
|
||||
for (const t of targets) {
|
||||
const list = map.get(t.group) ?? [];
|
||||
list.push(t);
|
||||
map.set(t.group, list);
|
||||
}
|
||||
return map;
|
||||
}, [targets]);
|
||||
|
||||
const setAxis = (i: number, patch: Partial<SweepSpec>) => {
|
||||
setSweeps((all) => all.map((s, j) => (j === i ? { ...s, ...patch } : s)));
|
||||
};
|
||||
|
||||
const pickTarget = (i: number, path: string) => {
|
||||
const t = targets.find((x) => x.path === path);
|
||||
if (!t) {
|
||||
setAxis(i, { path });
|
||||
return;
|
||||
}
|
||||
setAxis(i, {
|
||||
path: t.path,
|
||||
label: t.label,
|
||||
kind: t.kind,
|
||||
valuesText: suggestValues(t.current, t.paramKey),
|
||||
});
|
||||
};
|
||||
|
||||
const addAxis = (path?: string) => {
|
||||
const t =
|
||||
(path ? targets.find((x) => x.path === path) : undefined) ??
|
||||
targets.find(
|
||||
(x) =>
|
||||
x.group === "boundaries" &&
|
||||
!sweeps.some((s) => s.path === x.path),
|
||||
) ??
|
||||
targets.find((x) => !sweeps.some((s) => s.path === x.path)) ??
|
||||
targets[0];
|
||||
if (!t) return;
|
||||
setSweeps((s) => [
|
||||
...s,
|
||||
{
|
||||
path: t.path,
|
||||
label: t.label,
|
||||
kind: t.kind,
|
||||
valuesText: suggestValues(t.current, t.paramKey),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const resetToWaterTemps = () => {
|
||||
setSweeps(defaultSweepsFromDiagram(diagramNodes, fluid));
|
||||
setResults(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const onRun = async () => {
|
||||
setError(null);
|
||||
const problems = validateConfig(nodes, edges);
|
||||
if (problems.length > 0) {
|
||||
setError(problems[0]);
|
||||
return;
|
||||
}
|
||||
if (cases.length === 0) {
|
||||
setError("Aucun cas à lancer — renseigne des valeurs sur chaque axe.");
|
||||
return;
|
||||
}
|
||||
if (cases.length > 64) {
|
||||
setError(`Trop de cas (${cases.length}). Maximum 64 — réduis les listes de valeurs.`);
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setProgress({ done: 0, total: cases.length });
|
||||
setResults(null);
|
||||
try {
|
||||
const out = await runParallel(cases, {
|
||||
concurrency,
|
||||
onProgress: (done, total) => setProgress({ done, total }),
|
||||
});
|
||||
setResults(out);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const boundaryCount = targets.filter((t) => t.group === "boundaries").length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--panel)] shadow-xl">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--line)] px-4 py-3">
|
||||
<Layers size={16} className="text-[var(--accent)]" />
|
||||
<div className="flex-1">
|
||||
<h2 className="text-[14px] font-semibold text-[var(--ink)]">Multi-run</h2>
|
||||
<p className="text-[11px] text-[var(--ink-faint)]">
|
||||
Les variables viennent du schéma actuel (sources eau/air, UA, etc.).
|
||||
{boundaryCount > 0
|
||||
? ` ${boundaryCount} condition(s) limite détectée(s).`
|
||||
: " Aucune source eau/air — ajoute des BrineSource pour balayer T eau."}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="toolish" onClick={onClose} aria-label="Fermer">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-[var(--line)] bg-[var(--chrome-2)] px-2.5 py-1 text-[11px] text-[var(--ink-dim)] hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
||||
onClick={resetToWaterTemps}
|
||||
>
|
||||
Préremplir T eau évap + cond
|
||||
</button>
|
||||
<span className="text-[10px] text-[var(--ink-faint)]">
|
||||
Ex. : balayer <span className="mono">evap_water_in.t_set_c</span> et{" "}
|
||||
<span className="mono">cond_water_in.t_set_c</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="eyebrow">Axes à balayer</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[12px] text-[var(--accent)]"
|
||||
onClick={() => addAxis()}
|
||||
disabled={targets.length === 0}
|
||||
>
|
||||
<Plus size={12} /> Ajouter un axe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{targets.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-[var(--line)] p-3 text-[12px] text-[var(--warn)]">
|
||||
Schéma vide — charge un exemple ou place des composants avant le multi-run.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sweeps.map((sweep, i) => {
|
||||
const selected = targets.find((t) => t.path === sweep.path);
|
||||
return (
|
||||
<div
|
||||
key={`${sweep.path}-${i}`}
|
||||
className="grid grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_auto] items-end gap-2 rounded-md border border-[var(--line)] bg-[var(--chrome)] p-2.5"
|
||||
>
|
||||
<label className="flex min-w-0 flex-col gap-1">
|
||||
<span className="eyebrow">Variable du schéma</span>
|
||||
<select
|
||||
className="ek-select w-full"
|
||||
value={sweep.path}
|
||||
onChange={(e) => pickTarget(i, e.target.value)}
|
||||
>
|
||||
{[...targetsByGroup.entries()].map(([group, list]) => (
|
||||
<optgroup key={group} label={GROUP_LABEL[group]}>
|
||||
{list.map((t) => (
|
||||
<option key={t.path} value={t.path}>
|
||||
{t.label}
|
||||
{t.current !== undefined ? ` [actuel: ${t.current}]` : ""}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
{!selected && (
|
||||
<option value={sweep.path}>{sweep.path} (hors liste)</option>
|
||||
)}
|
||||
</select>
|
||||
<span className="mono truncate text-[10px] text-[var(--ink-faint)]">
|
||||
{sweep.path}
|
||||
{selected?.current !== undefined ? ` · actuel ${selected.current}` : ""}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-0 flex-col gap-1">
|
||||
<span className="eyebrow">
|
||||
Valeurs {selected?.unit ? `(${selected.unit})` : ""} — séparées par des virgules
|
||||
</span>
|
||||
<input
|
||||
className="ek-select mono w-full"
|
||||
value={sweep.valuesText}
|
||||
onChange={(e) => setAxis(i, { valuesText: e.target.value })}
|
||||
placeholder={
|
||||
selected?.paramKey === "t_set_c"
|
||||
? "10, 12, 14"
|
||||
: "val1, val2, val3"
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mb-0.5 rounded p-1.5 text-[var(--ink-faint)] hover:bg-[var(--chrome-2)]"
|
||||
onClick={() => setSweeps((all) => all.filter((_, j) => j !== i))}
|
||||
disabled={sweeps.length <= 1}
|
||||
aria-label="Retirer l’axe"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-[12px] text-[var(--ink-dim)]">
|
||||
Parallélisme
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
className="ek-select mono w-16"
|
||||
value={concurrency}
|
||||
onChange={(e) => setConcurrency(Number(e.target.value) || 1)}
|
||||
/>
|
||||
</label>
|
||||
<span className="mono text-[11px] text-[var(--ink-faint)]">
|
||||
{cases.length} cas · produit cartésien des axes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[12px] text-[var(--warn)]">{error}</p>}
|
||||
|
||||
{results && (
|
||||
<div className="overflow-x-auto rounded-md border border-[var(--line)]">
|
||||
<table className="w-full text-left text-[11px]">
|
||||
<thead className="bg-[var(--chrome)] text-[var(--ink-faint)]">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 font-medium">Cas</th>
|
||||
<th className="px-2 py-1.5 font-medium">Statut</th>
|
||||
<th className="px-2 py-1.5 font-medium">COP</th>
|
||||
<th className="px-2 py-1.5 font-medium">Qcool kW</th>
|
||||
<th className="px-2 py-1.5 font-medium">Wcomp kW</th>
|
||||
<th className="px-2 py-1.5 font-medium">ms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r) => {
|
||||
const k = extractKpis(r.result);
|
||||
return (
|
||||
<tr key={r.case.id} className="border-t border-[var(--line)]">
|
||||
<td className="max-w-[280px] px-2 py-1.5 font-medium text-[var(--ink)]">
|
||||
{r.case.label}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono">
|
||||
{r.ok ? k.status : r.error ?? "failed"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono">
|
||||
{k.cop != null ? k.cop.toFixed(3) : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono">
|
||||
{k.qCoolKw != null ? k.qCoolKw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono">
|
||||
{k.powerKw != null ? k.powerKw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono">{Math.round(r.durationMs)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-[var(--line)] px-4 py-3">
|
||||
<span className="mono text-[11px] text-[var(--ink-faint)]">
|
||||
{running
|
||||
? `Calcul ${progress.done}/${progress.total}…`
|
||||
: "Chaque cas = un POST /api/simulate (moteur CLI)"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={running || nodes.length === 0}
|
||||
onClick={() => void onRun()}
|
||||
className="flex items-center gap-1.5 rounded-md bg-[var(--accent)] px-4 py-1.5 text-[13px] font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{running ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
||||
{running ? "En cours" : "Lancer tous les cas"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.toolish {
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.toolish:hover {
|
||||
background: var(--chrome-2);
|
||||
}
|
||||
:global(.ek-select) {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--chrome-2);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
907
apps/web/src/components/panels/PropertiesPanel.tsx
Normal file
907
apps/web/src/components/panels/PropertiesPanel.tsx
Normal file
@@ -0,0 +1,907 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Parameter dialog — Dymola / OMEdit style.
|
||||
*
|
||||
* Layout (simulation tools convention):
|
||||
* ┌ Identity (name, type) ─────────────┐
|
||||
* │ [General] [Model] [Secondary] … │ tabs
|
||||
* │ Name Value Unit Fixed│ table header
|
||||
* │ UA [8000 ] W/K — │
|
||||
* │ SST [5 ] °C [✓] │
|
||||
* │ Z_UA [1.0 ] — [ ] │
|
||||
* └────────────────────────────────────┘
|
||||
*
|
||||
* Fixed checkbox (EES / Dymola): ON = imposed, OFF = free for solver.
|
||||
* Descriptions are tooltips only — no walls of text.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
import {
|
||||
boundaryFixPairingPatches,
|
||||
isBoundaryParamFixed,
|
||||
} from "@/lib/boundaryFix";
|
||||
import {
|
||||
COMPONENT_BY_TYPE,
|
||||
effectiveParamValue,
|
||||
fixedFlagKey,
|
||||
isParamFixed,
|
||||
isSecondaryPort,
|
||||
mergeDefaultParams,
|
||||
type ParamMeta,
|
||||
} from "@/lib/componentMeta";
|
||||
import {
|
||||
parseControlObjectives,
|
||||
CONTROL_NODE_TYPE,
|
||||
type ControlObjectiveConfig,
|
||||
type EntropykNodeData,
|
||||
} from "@/lib/configBuilder";
|
||||
import { buildComponentInspector } from "@/lib/componentInspector";
|
||||
import { ComponentIcon } from "@/components/canvas/ComponentIcon";
|
||||
import ComponentDocPanel from "@/components/panels/ComponentDocPanel";
|
||||
import { modelBannerForType } from "@/lib/componentDocMap";
|
||||
import { ArrowDown, ArrowUp, HelpCircle, Plus, Trash2 } from "lucide-react";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
|
||||
type StoreSnapshot = ReturnType<typeof useDiagramStore.getState>;
|
||||
type DiagramNode = StoreSnapshot["nodes"][number];
|
||||
|
||||
const BOUNDARY_TYPES = new Set(["BrineSource", "BrineSink", "AirSource", "AirSink"]);
|
||||
|
||||
/** Map raw sections → a small set of clean tabs (like Dymola dialog groups). */
|
||||
const TAB_ORDER = ["General", "Model", "Secondary", "Calibration", "Control", "Advanced"] as const;
|
||||
type TabId = (typeof TAB_ORDER)[number];
|
||||
|
||||
function tabForParam(p: ParamMeta, componentType: string): TabId {
|
||||
const s = (p.section ?? "").toLowerCase();
|
||||
// Calibration Fixed/Free only on HX factors & measure targets
|
||||
if (p.fixable && (p.measureOutput || p.actuatorFactor || s.includes("calibration"))) {
|
||||
return "Calibration";
|
||||
}
|
||||
if (s.includes("calibration")) return "Calibration";
|
||||
if (s.includes("secondary") || s.includes("rating") || s.includes("caloporteur")) return "Secondary";
|
||||
// "Control" tab only for the optional regulation-loop component
|
||||
if (componentType === "SaturatedController") {
|
||||
if (s.includes("mesure") || s.includes("measure") || s.includes("primary")) return "General";
|
||||
if (s.includes("actionneur") || s.includes("actuator") || s.includes("bounded")) return "Model";
|
||||
return "Advanced";
|
||||
}
|
||||
if (s.includes("control") && !s.includes("quality")) return "Advanced";
|
||||
if (
|
||||
p.advanced ||
|
||||
s.includes("solver") ||
|
||||
s.includes("hydraulic") ||
|
||||
s.includes("advanced") ||
|
||||
s.includes("manufacturer data") ||
|
||||
s.includes("réglages fins")
|
||||
) {
|
||||
return "Advanced";
|
||||
}
|
||||
if (
|
||||
s.includes("identification") ||
|
||||
s.includes("fluid") ||
|
||||
s === "" ||
|
||||
s.includes("component parameter")
|
||||
) {
|
||||
return "General";
|
||||
}
|
||||
return "Model";
|
||||
}
|
||||
|
||||
function hasLiveSecondaryPorts(node: DiagramNode, edges: Edge[]): boolean {
|
||||
const connected = new Set<string>();
|
||||
for (const e of edges) {
|
||||
if (e.source === node.id && e.sourceHandle) connected.add(e.sourceHandle);
|
||||
if (e.target === node.id && e.targetHandle) connected.add(e.targetHandle);
|
||||
}
|
||||
return connected.has("secondary_inlet") && connected.has("secondary_outlet");
|
||||
}
|
||||
|
||||
export default function PropertiesPanel() {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
result,
|
||||
selectedNodeId,
|
||||
updateNodeParams,
|
||||
updateNodesParams,
|
||||
updateNodeName,
|
||||
updateNodeCircuit,
|
||||
removeNode,
|
||||
} = useDiagramStore();
|
||||
|
||||
const node = nodes.find((n) => n.id === selectedNodeId) as DiagramNode | undefined;
|
||||
const [tab, setTab] = useState<TabId>("General");
|
||||
/** Modelica: Parameters vs Results (Variable Browser). */
|
||||
const [panelMode, setPanelMode] = useState<"parameters" | "results">("parameters");
|
||||
// Doc technique repliée par défaut — les paramètres passent toujours en premier
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const liveSecondary = useMemo(
|
||||
() => (node ? hasLiveSecondaryPorts(node, edges) : false),
|
||||
[node, edges],
|
||||
);
|
||||
|
||||
const inspector = useMemo(() => {
|
||||
if (!node) return null;
|
||||
return buildComponentInspector(
|
||||
node as Node<EntropykNodeData>,
|
||||
nodes as Node<EntropykNodeData>[],
|
||||
edges,
|
||||
result,
|
||||
);
|
||||
}, [node, nodes, edges, result]);
|
||||
|
||||
// After a successful solve, jump to Results when selecting a part (Dymola-like).
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
if (result && String(result.status).toLowerCase() === "converged") {
|
||||
setPanelMode((mode) => (mode === "results" ? mode : "results"));
|
||||
}
|
||||
}, [node?.id, result?.status, result?.iterations]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Always fill missing defaults (Z_UA=1, etc.) when a node is selected — once per id.
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
const merged = mergeDefaultParams(node.data.type, node.data.params ?? {});
|
||||
const missing = Object.keys(merged).some(
|
||||
(k) => node.data.params[k] === undefined && merged[k] !== undefined,
|
||||
);
|
||||
if (missing) {
|
||||
updateNodeParams(node.id, merged);
|
||||
}
|
||||
// Intentionally only when selection / type changes — not on every params update.
|
||||
}, [node?.id, node?.data.type]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="flex flex-col border-b border-[var(--line)]">
|
||||
<div className="flex h-9 items-center px-3">
|
||||
<span className="text-[11px] font-semibold text-[var(--ink-dim)]">Parameters</span>
|
||||
</div>
|
||||
<div className="space-y-2 px-3 pb-4 text-[12px] leading-relaxed text-[var(--ink-dim)]">
|
||||
<p>Sélectionne un composant sur le schéma pour voir ses paramètres.</p>
|
||||
<p className="text-[11px] text-[var(--ink-faint)]">
|
||||
Après Simulate, clique un composant pour ouvrir ses variables (P, T, ṁ, ΔP…) comme dans
|
||||
Modelica / Dymola.
|
||||
</p>
|
||||
<div className="rounded border border-[var(--line)] bg-[var(--chrome-2)] p-2 text-[10px]">
|
||||
<p className="mb-1 font-semibold text-[var(--ink)]">Rappel rapide</p>
|
||||
<ul className="list-inside list-disc space-y-0.5 text-[var(--ink-dim)]">
|
||||
<li>
|
||||
<strong>Fixed ☑</strong> = valeur imposée
|
||||
</li>
|
||||
<li>
|
||||
<strong>Fixed ☐</strong> = le solveur calcule (ex. Z_UA libre)
|
||||
</li>
|
||||
<li>
|
||||
Calibration simple : Fixed sur SST + Z_UA non Fixed (défaut Z_UA = 1)
|
||||
</li>
|
||||
<li>
|
||||
Le bloc « Regulation loop » (palette Advanced) est optionnel — pas besoin pour
|
||||
calibrer Z_UA
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||
if (!meta) {
|
||||
return (
|
||||
<div className="px-3 py-4 text-[12px] text-[var(--hot)]">
|
||||
Unknown component type: {node.data.type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const params = mergeDefaultParams(node.data.type, node.data.params ?? {});
|
||||
|
||||
// Group params into tabs
|
||||
const byTab = new Map<TabId, ParamMeta[]>();
|
||||
for (const p of meta.params) {
|
||||
const t = tabForParam(p, node.data.type);
|
||||
const list = byTab.get(t) ?? [];
|
||||
list.push(p);
|
||||
byTab.set(t, list);
|
||||
}
|
||||
// Never show empty "Control" tab for normal components
|
||||
const availableTabs = TAB_ORDER.filter((t) => (byTab.get(t)?.length ?? 0) > 0);
|
||||
const activeTab = availableTabs.includes(tab) ? tab : availableTabs[0] ?? "General";
|
||||
const rows = byTab.get(activeTab) ?? [];
|
||||
const showFixedCol = rows.some((p) => p.fixable);
|
||||
const isRegLoop = node.data.type === "SaturatedController";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-b border-[var(--line)]">
|
||||
{/* ── Title bar ── */}
|
||||
<div className="flex h-9 items-center justify-between gap-2 border-b border-[var(--line)] bg-[var(--chrome-2)] px-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-6 w-6 shrink-0 place-items-center rounded border border-[var(--line)] bg-white">
|
||||
<span style={{ width: 14, height: 14 }}>
|
||||
<ComponentIcon type={node.data.type} color={meta.color} />
|
||||
</span>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold text-[var(--ink)]">
|
||||
{node.data.name}
|
||||
<span className="ml-1 font-normal text-[var(--ink-faint)]">· {meta.label}</span>
|
||||
</div>
|
||||
<div className="truncate text-[9px] text-[var(--ink-faint)]">
|
||||
{panelMode === "results" && inspector?.summaryLines[0]
|
||||
? inspector.summaryLines.slice(0, 2).join(" · ")
|
||||
: meta.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHelp((v) => !v)}
|
||||
className={`rounded p-1 ${showHelp ? "bg-white text-[var(--accent)]" : "text-[var(--ink-faint)] hover:bg-white"}`}
|
||||
title="Documentation technique (bas du panneau)"
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeNode(node.id)}
|
||||
className="rounded p-1 text-[var(--ink-faint)] hover:bg-red-50 hover:text-[var(--hot)]"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modelica: Parameters ↔ Results (Variable Browser) */}
|
||||
{!isRegLoop && (
|
||||
<div className="grid grid-cols-2 border-b border-[var(--line)] bg-white text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelMode("parameters")}
|
||||
className={`py-1.5 font-medium ${
|
||||
panelMode === "parameters"
|
||||
? "border-b-2 border-[var(--accent)] text-[var(--ink)]"
|
||||
: "text-[var(--ink-faint)] hover:text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
Parameters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelMode("results")}
|
||||
className={`py-1.5 font-medium ${
|
||||
panelMode === "results"
|
||||
? "border-b-2 border-[var(--accent)] text-[var(--ink)]"
|
||||
: "text-[var(--ink-faint)] hover:text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
Results
|
||||
{result ? (
|
||||
<span className="ml-1 mono text-[9px] text-[var(--ok)]">●</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRegLoop && panelMode === "results" && (
|
||||
<ModelicaResultsView inspector={inspector} hasResult={!!result} />
|
||||
)}
|
||||
|
||||
{(isRegLoop || panelMode === "parameters") && (
|
||||
<>
|
||||
{isRegLoop && (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-2.5 py-1.5 text-[10px] text-amber-950">
|
||||
Optionnel. Calibration SST + Z_UA → onglet <strong>Calibration</strong> du HX (cases
|
||||
Fixed), pas ce nœud.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model / correlation banner (compressors, HX, …) */}
|
||||
{modelBannerForType(node.data.type) && (
|
||||
<div className="border-b border-[var(--line)] bg-[#eff6ff] px-2.5 py-1.5 text-[10px] leading-snug text-[#1e3a5f]">
|
||||
<span className="font-semibold">Modèle : </span>
|
||||
<span className="mono">{modelBannerForType(node.data.type)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Identity row (always first after header) ── */}
|
||||
<div className="grid grid-cols-[1fr_72px] gap-2 border-b border-[var(--line)] px-2.5 py-2">
|
||||
<label className="min-w-0">
|
||||
<span className="mb-0.5 block text-[9px] text-[var(--ink-faint)]">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={node.data.name}
|
||||
onChange={(e) => updateNodeName(node.id, e.target.value)}
|
||||
className="param-input mono w-full"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="mb-0.5 block text-[9px] text-[var(--ink-faint)]">Circuit</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={node.data.circuit}
|
||||
onChange={(e) => updateNodeCircuit(node.id, parseInt(e.target.value || "0", 10))}
|
||||
className="param-input mono w-full text-right"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Secondary status — one line only */}
|
||||
{meta.ports?.some(isSecondaryPort) && (
|
||||
<div
|
||||
className={`border-b px-2.5 py-1.5 text-[10px] ${
|
||||
liveSecondary
|
||||
? "border-emerald-100 bg-emerald-50/80 text-emerald-900"
|
||||
: "border-amber-100 bg-amber-50/80 text-amber-950"
|
||||
}`}
|
||||
>
|
||||
{liveSecondary
|
||||
? "Secondary loop connected"
|
||||
: "Secondary ports open — connect Source / Sink, or use rating values"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
{availableTabs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-0 border-b border-[var(--line)] bg-[var(--chrome-2)] px-1">
|
||||
{availableTabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-2.5 py-1.5 text-[11px] font-medium transition-colors ${
|
||||
activeTab === t
|
||||
? "border-b-2 border-[var(--accent)] text-[var(--ink)]"
|
||||
: "text-[var(--ink-faint)] hover:text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Parameter table ── */}
|
||||
<div className="max-h-[min(48vh,380px)] overflow-y-auto">
|
||||
{rows.length === 0 ? (
|
||||
<p className="px-3 py-4 text-[11px] text-[var(--ink-faint)]">No parameters in this tab.</p>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-[11px]">
|
||||
<thead className="sticky top-0 z-[1] bg-[var(--chrome-2)]">
|
||||
<tr className="border-b border-[var(--line)] text-left text-[9px] font-semibold uppercase tracking-wide text-[var(--ink-faint)]">
|
||||
<th className="px-2 py-1.5 font-semibold">Parameter</th>
|
||||
<th className="w-[108px] px-1 py-1.5 font-semibold">Value</th>
|
||||
<th className="w-12 px-1 py-1.5 font-semibold">Unit</th>
|
||||
{showFixedCol && (
|
||||
<th
|
||||
className="w-12 px-1 py-1.5 text-center font-semibold"
|
||||
title="Fixed = imposed · Free = solved"
|
||||
>
|
||||
Fixed
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((p) => {
|
||||
const fixed = BOUNDARY_TYPES.has(node.data.type)
|
||||
? isBoundaryParamFixed(params, p)
|
||||
: isParamFixed(params, p);
|
||||
const free = Boolean(p.fixable && !fixed);
|
||||
const displayVal = effectiveParamValue(params, p);
|
||||
return (
|
||||
<tr
|
||||
key={p.key}
|
||||
className="border-b border-[var(--line)]/70 hover:bg-[var(--chrome-2)]/80"
|
||||
title={p.description ?? p.label}
|
||||
>
|
||||
<td
|
||||
className="max-w-[140px] truncate px-2 py-1 text-[var(--ink-dim)]"
|
||||
title={p.description ?? p.label}
|
||||
>
|
||||
{p.label}
|
||||
{p.required ? <span className="text-[var(--hot)]"> *</span> : null}
|
||||
</td>
|
||||
<td className="px-1 py-0.5">
|
||||
<ParamCell
|
||||
node={node}
|
||||
param={p}
|
||||
nodes={nodes}
|
||||
free={free}
|
||||
displayValue={displayVal}
|
||||
updateNodeParams={updateNodeParams}
|
||||
/>
|
||||
</td>
|
||||
<td className="mono px-1 py-1 text-[10px] text-[var(--ink-faint)]">
|
||||
{p.unit ?? ""}
|
||||
</td>
|
||||
{showFixedCol && (
|
||||
<td className="px-1 py-1 text-center">
|
||||
{p.fixable ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fixed}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
const seed =
|
||||
node.data.params[p.key] === undefined &&
|
||||
p.default !== undefined
|
||||
? { [p.key]: p.default }
|
||||
: {};
|
||||
|
||||
if (BOUNDARY_TYPES.has(node.data.type)) {
|
||||
const patches = boundaryFixPairingPatches(
|
||||
node.id,
|
||||
p.key,
|
||||
checked,
|
||||
nodes as Node<EntropykNodeData>[],
|
||||
edges,
|
||||
);
|
||||
const self = patches.get(node.id) ?? {};
|
||||
patches.set(node.id, { ...seed, ...self });
|
||||
updateNodesParams(patches);
|
||||
} else {
|
||||
updateNodeParams(node.id, {
|
||||
[fixedFlagKey(p.key)]: checked,
|
||||
...seed,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="h-3.5 w-3.5 accent-[var(--accent)]"
|
||||
title={
|
||||
fixed
|
||||
? "Fixed : valeur imposée"
|
||||
: "Free : le solveur calcule (valeur = départ)"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[var(--ink-faint)]">·</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{activeTab === "Calibration" && (
|
||||
<div className="space-y-1 border-t border-[var(--line)] bg-[var(--chrome-2)] px-2.5 py-1.5 text-[10px] leading-snug text-[var(--ink-dim)]">
|
||||
<p>
|
||||
<strong>Comment calibrer :</strong> coche Fixed sur la cible (SST/SDT), décoche
|
||||
Fixed sur le facteur (Z_UA). Z_UA vaut <strong>1</strong> par défaut (pas de
|
||||
correction).
|
||||
</p>
|
||||
<p className="text-[var(--ink-faint)]">
|
||||
Tu n’as pas besoin du nœud « Regulation loop » pour ça.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRegLoop && (
|
||||
<div className="border-t border-[var(--line)] p-2">
|
||||
<OverrideEditor node={node} nodes={nodes} updateNodeParams={updateNodeParams} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Doc technique (collapsed by default — never above params) ── */}
|
||||
<div className="border-t border-[var(--line)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHelp((v) => !v)}
|
||||
className="flex w-full items-center justify-between px-2.5 py-1.5 text-[10px] font-medium text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HelpCircle size={12} />
|
||||
Documentation technique
|
||||
</span>
|
||||
<span className="text-[var(--ink-faint)]">{showHelp ? "−" : "+"}</span>
|
||||
</button>
|
||||
{showHelp && (
|
||||
<ComponentDocPanel componentType={node.data.type} fallbackHelp={meta.help} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
:global(.param-input) {
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
color: var(--ink);
|
||||
}
|
||||
:global(.param-input:focus) {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
:global(.param-input.free) {
|
||||
border-color: #86efac;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParamCell({
|
||||
node,
|
||||
param,
|
||||
nodes,
|
||||
free,
|
||||
displayValue,
|
||||
updateNodeParams,
|
||||
}: {
|
||||
node: DiagramNode;
|
||||
param: ParamMeta;
|
||||
nodes: DiagramNode[];
|
||||
free: boolean;
|
||||
displayValue: number | string | boolean | undefined;
|
||||
updateNodeParams: (id: string, params: Record<string, number | string | boolean>) => void;
|
||||
}) {
|
||||
const update = (next: number | string | boolean) => updateNodeParams(node.id, { [param.key]: next });
|
||||
const selectOptions = controllerSelectOptions(node, param.key, nodes);
|
||||
const options = selectOptions ?? param.options;
|
||||
const cls = free ? "param-input free mono" : "param-input mono";
|
||||
const value = displayValue;
|
||||
|
||||
if (options) {
|
||||
return (
|
||||
<select
|
||||
value={String(value ?? param.default ?? options[0]?.value ?? "")}
|
||||
onChange={(e) => update(e.target.value)}
|
||||
className="param-input w-full"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.kind === "boolean") {
|
||||
return (
|
||||
<div className="flex justify-end pr-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => update(e.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const numOrText =
|
||||
value === undefined || value === null
|
||||
? ""
|
||||
: typeof value === "number" && !Number.isFinite(value)
|
||||
? ""
|
||||
: String(value);
|
||||
|
||||
return (
|
||||
<input
|
||||
type={param.kind === "number" ? "number" : "text"}
|
||||
min={param.kind === "number" ? param.min : undefined}
|
||||
max={param.kind === "number" ? param.max : undefined}
|
||||
step={param.kind === "number" ? (param.step ?? "any") : undefined}
|
||||
value={numOrText}
|
||||
onChange={(e) => {
|
||||
if (param.kind === "number") {
|
||||
const n = parseFloat(e.target.value);
|
||||
update(Number.isFinite(n) ? n : (param.default as number) ?? 0);
|
||||
} else {
|
||||
update(e.target.value);
|
||||
}
|
||||
}}
|
||||
className={`${cls} w-full text-right`}
|
||||
title={
|
||||
free
|
||||
? "Point de départ (paramètre libre)"
|
||||
: param.default !== undefined
|
||||
? `Défaut : ${param.default}`
|
||||
: undefined
|
||||
}
|
||||
placeholder={param.default !== undefined ? String(param.default) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Saturated controller overrides (kept compact) ───────────────────── */
|
||||
|
||||
const CONTROL_OUTPUT_OPTIONS = [
|
||||
{ value: "temperature", label: "Temperature" },
|
||||
{ value: "pressure", label: "Pressure" },
|
||||
{ value: "massFlowRate", label: "Mass flow rate" },
|
||||
{ value: "capacity", label: "Capacity" },
|
||||
{ value: "heatTransferRate", label: "Heat transfer rate" },
|
||||
{ value: "superheat", label: "Superheat" },
|
||||
{ value: "subcooling", label: "Subcooling" },
|
||||
{ value: "saturationTemperature", label: "Saturation temperature" },
|
||||
] as const;
|
||||
|
||||
function OverrideEditor({
|
||||
node,
|
||||
nodes,
|
||||
updateNodeParams,
|
||||
}: {
|
||||
node: DiagramNode;
|
||||
nodes: DiagramNode[];
|
||||
updateNodeParams: (id: string, params: Record<string, number | string | boolean>) => void;
|
||||
}) {
|
||||
const objectives = parseControlObjectives(node.data.params.objectives_json);
|
||||
const componentNodes = nodes.filter((c) => c.data.type !== "SaturatedController");
|
||||
const write = (next: ControlObjectiveConfig[]) =>
|
||||
updateNodeParams(node.id, { objectives_json: JSON.stringify(next) });
|
||||
const updateObjective = (index: number, patch: Partial<ControlObjectiveConfig>) =>
|
||||
write(objectives.map((o, i) => (i === index ? { ...o, ...patch } : o)));
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= objectives.length) return;
|
||||
const next = [...objectives];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
write(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded border border-[var(--line)] bg-white">
|
||||
<div className="flex items-center justify-between border-b border-[var(--line)] px-2 py-1.5">
|
||||
<span className="text-[10px] font-semibold text-[var(--ink-dim)]">Override chain</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
write([
|
||||
...objectives,
|
||||
{
|
||||
component: componentNodes[0]?.data.name ?? "component",
|
||||
output: "temperature",
|
||||
setpoint: 0,
|
||||
gain: 1,
|
||||
combine: "min",
|
||||
},
|
||||
])
|
||||
}
|
||||
className="flex items-center gap-0.5 text-[10px] font-medium text-[var(--accent)]"
|
||||
>
|
||||
<Plus size={12} /> Add
|
||||
</button>
|
||||
</div>
|
||||
{objectives.length === 0 ? (
|
||||
<p className="px-2 py-2 text-[10px] text-[var(--ink-faint)]">Primary objective only.</p>
|
||||
) : (
|
||||
<div className="space-y-1 p-1.5">
|
||||
{objectives.map((objective, index) => (
|
||||
<div key={index} className="rounded border border-[var(--line)] p-1.5">
|
||||
<div className="mb-1 flex justify-end gap-0.5">
|
||||
<button type="button" onClick={() => move(index, -1)} className="p-0.5 text-[var(--ink-faint)]">
|
||||
<ArrowUp size={11} />
|
||||
</button>
|
||||
<button type="button" onClick={() => move(index, 1)} className="p-0.5 text-[var(--ink-faint)]">
|
||||
<ArrowDown size={11} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => write(objectives.filter((_, i) => i !== index))}
|
||||
className="p-0.5 text-[var(--ink-faint)] hover:text-[var(--hot)]"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<select
|
||||
value={objective.component}
|
||||
onChange={(e) => updateObjective(index, { component: e.target.value })}
|
||||
className="param-input mono"
|
||||
>
|
||||
{componentNodes.map((c) => (
|
||||
<option key={c.id} value={c.data.name}>
|
||||
{c.data.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={objective.output}
|
||||
onChange={(e) => updateObjective(index, { output: e.target.value })}
|
||||
className="param-input"
|
||||
>
|
||||
{CONTROL_OUTPUT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={objective.setpoint}
|
||||
onChange={(e) => updateObjective(index, { setpoint: Number(e.target.value) })}
|
||||
className="param-input mono text-right"
|
||||
title="Setpoint"
|
||||
/>
|
||||
<select
|
||||
value={objective.combine}
|
||||
onChange={(e) =>
|
||||
updateObjective(index, { combine: e.target.value as "min" | "max" })
|
||||
}
|
||||
className="param-input mono"
|
||||
>
|
||||
<option value="min">MIN</option>
|
||||
<option value="max">MAX</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function controllerSelectOptions(
|
||||
node: DiagramNode,
|
||||
key: string,
|
||||
nodes: DiagramNode[],
|
||||
): Array<{ value: string; label: string }> | null {
|
||||
if (node.data.type !== "SaturatedController") return null;
|
||||
const componentNodes = nodes.filter((c) => c.data.type !== "SaturatedController");
|
||||
|
||||
if (key === "measure_component" || key === "actuator_component") {
|
||||
return componentNodes.map((c) => ({
|
||||
value: c.data.name,
|
||||
label: `${c.data.name} (${c.data.type})`,
|
||||
}));
|
||||
}
|
||||
if (key === "measure_output") return [...CONTROL_OUTPUT_OPTIONS];
|
||||
if (key === "actuator_factor") {
|
||||
return [
|
||||
{ value: "injection", label: "injection" },
|
||||
{ value: "opening", label: "opening" },
|
||||
{ value: "z_flow", label: "z_flow" },
|
||||
{ value: "z_dp", label: "z_dp" },
|
||||
{ value: "z_ua", label: "z_ua" },
|
||||
{ value: "z_power", label: "z_power" },
|
||||
{ value: "z_etav", label: "z_etav" },
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── Modelica / Dymola Variable Browser for one component ─────────────── */
|
||||
|
||||
function ModelicaResultsView({
|
||||
inspector,
|
||||
hasResult,
|
||||
}: {
|
||||
inspector: ReturnType<typeof buildComponentInspector> | null;
|
||||
hasResult: boolean;
|
||||
}) {
|
||||
if (!hasResult || !inspector) {
|
||||
return (
|
||||
<div className="space-y-2 px-3 py-4 text-[11px] text-[var(--ink-dim)]">
|
||||
<p className="font-semibold text-[var(--ink)]">Pas encore de résultats</p>
|
||||
<p>
|
||||
Lance <strong>Simulate</strong>, puis reclique ce composant — tu verras P, T, Tsat, ṁ, ΔP,
|
||||
Q̇ comme dans le Variable Browser Modelica.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { title, typeLabel, summaryLines, ports } = inspector;
|
||||
|
||||
return (
|
||||
<div className="max-h-[min(56vh,480px)] space-y-2 overflow-y-auto px-2.5 py-2.5">
|
||||
<div className="rounded-md border border-[var(--line)] bg-[var(--chrome-2)] px-2.5 py-2">
|
||||
<div className="mono text-[12px] font-semibold text-[var(--ink)]">{title}</div>
|
||||
<div className="text-[10px] text-[var(--ink-faint)]">{typeLabel}</div>
|
||||
{summaryLines.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{summaryLines.map((line) => (
|
||||
<span
|
||||
key={line}
|
||||
className="mono rounded bg-white px-1.5 py-0.5 text-[10px] text-[var(--ink-dim)] ring-1 ring-[var(--line)]"
|
||||
>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{inspector.delta_p_bar != null && (
|
||||
<MiniMetric label="ΔP" value={`${inspector.delta_p_bar.toFixed(3)} bar`} />
|
||||
)}
|
||||
{inspector.delta_p_sec_bar != null && (
|
||||
<MiniMetric label="ΔP sec" value={`${inspector.delta_p_sec_bar.toFixed(3)} bar`} />
|
||||
)}
|
||||
{inspector.q_primary_kw != null && (
|
||||
<MiniMetric label="Q̇" value={`${Math.abs(inspector.q_primary_kw).toFixed(2)} kW`} />
|
||||
)}
|
||||
{inspector.q_secondary_kw != null && (
|
||||
<MiniMetric
|
||||
label="Q̇ sec"
|
||||
value={`${Math.abs(inspector.q_secondary_kw).toFixed(2)} kW`}
|
||||
/>
|
||||
)}
|
||||
{inspector.work_kw != null && (
|
||||
<MiniMetric label="Ẇ" value={`${inspector.work_kw.toFixed(2)} kW`} />
|
||||
)}
|
||||
{inspector.superheat_k != null && (
|
||||
<MiniMetric label="Superheat" value={`${inspector.superheat_k.toFixed(2)} K`} />
|
||||
)}
|
||||
{inspector.subcooling_k != null && (
|
||||
<MiniMetric label="Subcooling" value={`${inspector.subcooling_k.toFixed(2)} K`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border border-[var(--line)]">
|
||||
<div className="border-b border-[var(--line)] bg-[var(--chrome-2)] px-2 py-1 text-[9px] font-semibold uppercase tracking-wide text-[var(--ink-faint)]">
|
||||
Ports / variables
|
||||
</div>
|
||||
{ports.length === 0 ? (
|
||||
<p className="px-2 py-3 text-[10px] text-[var(--ink-faint)]">Aucun port câblé.</p>
|
||||
) : (
|
||||
<table className="w-full text-[10px]">
|
||||
<thead>
|
||||
<tr className="text-left text-[9px] text-[var(--ink-faint)]">
|
||||
<th className="px-1.5 py-1 font-medium">Port</th>
|
||||
<th className="px-1 py-1 font-medium">P</th>
|
||||
<th className="px-1 py-1 font-medium">T</th>
|
||||
<th className="px-1 py-1 font-medium">Tsat</th>
|
||||
<th className="px-1 py-1 font-medium">h</th>
|
||||
<th className="px-1 py-1 font-medium">ṁ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ports.map((p) => (
|
||||
<tr key={`${p.port}-${p.edgeIndex}`} className="border-t border-[var(--line)] mono">
|
||||
<td className="px-1.5 py-1 text-[var(--ink)]">
|
||||
{p.port}
|
||||
<span className="ml-1 text-[8px] text-[var(--ink-faint)]">
|
||||
{p.stream === "secondary" ? "sec" : p.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 py-1">{fmtOpt(p.pressure_bar, 3)}</td>
|
||||
<td className="px-1 py-1">{fmtOpt(p.temperature_c, 1)}</td>
|
||||
<td className="px-1 py-1">{fmtOpt(p.tsat_c, 1)}</td>
|
||||
<td className="px-1 py-1">{fmtOpt(p.enthalpy_kj_kg, 1)}</td>
|
||||
<td className="px-1 py-1">{fmtOpt(p.mass_flow_kg_s, 4)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="border-t border-[var(--line)] px-2 py-1 text-[8px] text-[var(--ink-faint)]">
|
||||
Unités : P [bar] · T/Tsat [°C] · h [kJ/kg] · ṁ [kg/s]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded border border-[var(--line)] bg-[var(--chrome-2)] px-1.5 py-1">
|
||||
<div className="text-[8px] uppercase tracking-wide text-[var(--ink-faint)]">{label}</div>
|
||||
<div className="mono text-[11px] text-[var(--ink)]">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtOpt(v: number | null | undefined, digits: number): string {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "—";
|
||||
}
|
||||
1234
apps/web/src/components/panels/ResultsPanel.tsx
Normal file
1234
apps/web/src/components/panels/ResultsPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
162
apps/web/src/lib/api.ts
Normal file
162
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Client for the Entropyk REST API (Axum server in demo/).
|
||||
*
|
||||
* In development, requests go through the Next.js rewrite proxy
|
||||
* (/api/entropyk/* → http://localhost:3030/api/* by default; see next.config.mjs).
|
||||
*/
|
||||
|
||||
import type { ComponentMeta } from "./componentMeta";
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_URL || "/api/entropyk";
|
||||
|
||||
export interface IterationInfo {
|
||||
iteration: number;
|
||||
residual_norm: number;
|
||||
delta_norm: number;
|
||||
alpha?: number | null;
|
||||
max_residual_index?: number | null;
|
||||
max_residual?: number;
|
||||
}
|
||||
|
||||
export interface SimulationResult {
|
||||
input: string;
|
||||
status: "converged" | "failed" | "error" | string;
|
||||
convergence?: {
|
||||
iterations?: number;
|
||||
final_residual?: number;
|
||||
tolerance?: number;
|
||||
strategy?: string;
|
||||
iteration_history?: IterationInfo[];
|
||||
converged?: boolean;
|
||||
status?: string;
|
||||
};
|
||||
iterations?: number;
|
||||
state?: Array<{
|
||||
edge?: number;
|
||||
edge_id?: number;
|
||||
pressure_bar?: number;
|
||||
pressure_pa?: number;
|
||||
enthalpy_kj_kg?: number;
|
||||
enthalpy_j_kg?: number;
|
||||
mass_flow_kg_s?: number;
|
||||
source?: string;
|
||||
target?: string;
|
||||
source_port?: string;
|
||||
target_port?: string;
|
||||
temperature_c?: number;
|
||||
saturation_temperature_c?: number;
|
||||
}>;
|
||||
performance?: {
|
||||
q_cooling_kw?: number | null;
|
||||
q_heating_kw?: number | null;
|
||||
compressor_power_kw?: number | null;
|
||||
cop?: number | null;
|
||||
cooling_capacity_w?: number | null;
|
||||
heating_capacity_w?: number | null;
|
||||
compressor_power_w?: number | null;
|
||||
pump_power_w?: number | null;
|
||||
cop_cooling?: number | null;
|
||||
cop_heating?: number | null;
|
||||
};
|
||||
components?: Array<{
|
||||
name: string;
|
||||
component_type: string;
|
||||
circuit: number;
|
||||
inlet?: { pressure_pa: number; enthalpy_j_kg: number };
|
||||
outlet?: { pressure_pa: number; enthalpy_j_kg: number };
|
||||
energy?: { heat_transfer_w: number; work_w: number };
|
||||
}>;
|
||||
error?: string | null;
|
||||
failure_diagnostics?: {
|
||||
final_residual_norm?: number;
|
||||
last_residual_norm?: number;
|
||||
dominant_residual_index?: number;
|
||||
dominant_residual_value?: number;
|
||||
} | null;
|
||||
/** Degrees-of-freedom summary after topology finalize (CLI hard gate). */
|
||||
dof?: DofSummary | null;
|
||||
}
|
||||
|
||||
/** Degrees-of-freedom summary returned by the CLI after finalize. */
|
||||
export interface DofSummary {
|
||||
n_equations: number;
|
||||
n_unknowns: number;
|
||||
balance: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface SimulateResponse {
|
||||
ok: boolean;
|
||||
result?: SimulationResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MollierPoint {
|
||||
t_k: number;
|
||||
p_bar: number;
|
||||
h_liq_kj_kg: number;
|
||||
h_vap_kj_kg: number;
|
||||
}
|
||||
|
||||
export interface MollierResponse {
|
||||
ok: boolean;
|
||||
fluid: string;
|
||||
saturation: MollierPoint[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export async function fetchComponents(): Promise<ComponentMeta[]> {
|
||||
const res = await fetch(`${API_BASE}/components`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Failed to fetch components: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function simulate(config: unknown): Promise<SimulateResponse> {
|
||||
const healthy = await checkHealth();
|
||||
if (!healthy) {
|
||||
throw new Error(
|
||||
`Entropyk API is down (${API_BASE}/health). Start ui-server: cargo run -p entropyk-demo --bin ui-server`,
|
||||
);
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/simulate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
} catch (e) {
|
||||
const detail = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(
|
||||
`Cannot reach Entropyk API (${API_BASE}). ui-server may have crashed mid-solve (CoolProp). Restart it on :3030. ${detail}`,
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = (await res.text().catch(() => "")).trim();
|
||||
const hint =
|
||||
res.status === 500 || res.status === 502 || res.status === 504
|
||||
? " — ui-server likely crashed (CoolProp race) or was restarting. Restart: cargo run -p entropyk-demo --bin ui-server"
|
||||
: "";
|
||||
throw new Error(
|
||||
`Simulate request failed: ${res.status}${hint}${body ? `\n${body.slice(0, 400)}` : ""}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchMollier(fluid: string): Promise<MollierResponse> {
|
||||
const res = await fetch(`${API_BASE}/mollier?fluid=${encodeURIComponent(fluid)}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Failed to fetch Mollier data: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/health`, { cache: "no-store" });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
148
apps/web/src/lib/boundaryFix.test.ts
Normal file
148
apps/web/src/lib/boundaryFix.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
boundaryFixPairingPatches,
|
||||
enforceModelicaBoundaryEmit,
|
||||
findSecondaryLoopDofConflicts,
|
||||
hydrateBoundaryFixFlags,
|
||||
isBoundaryParamFixed,
|
||||
type BoundaryNodeData,
|
||||
} from "./boundaryFix";
|
||||
import { COMPONENT_BY_TYPE, fixedFlagKey } from "./componentMeta";
|
||||
import { buildScenarioConfig, stripUiOnlyParams, type EntropykNodeData } from "./configBuilder";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Node<BoundaryNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropyk",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, params },
|
||||
};
|
||||
}
|
||||
|
||||
function waterLoop(
|
||||
srcParams: Record<string, number | string | boolean>,
|
||||
sinkParams: Record<string, number | string | boolean>,
|
||||
) {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", { ua: 8000 }),
|
||||
node("s", "BrineSource", "src", srcParams),
|
||||
node("k", "BrineSink", "sink", sinkParams),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "1", source: "s", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
{ id: "2", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
describe("Modelica MassFlowSource_T defaults", () => {
|
||||
it("meta defaultFixed P is false (Free P)", () => {
|
||||
const metaP = COMPONENT_BY_TYPE.BrineSource!.params.find((p) => p.key === "p_set_bar")!;
|
||||
expect(metaP.defaultFixed).toBe(false);
|
||||
});
|
||||
|
||||
it("hydrate Free P when ṁ present without explicit fix_pressure", () => {
|
||||
const metaP = COMPONENT_BY_TYPE.BrineSource!.params.find((p) => p.key === "p_set_bar")!;
|
||||
const hydrated = hydrateBoundaryFixFlags("BrineSource", {
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.55,
|
||||
});
|
||||
expect(hydrated[fixedFlagKey("p_set_bar")]).toBe(false);
|
||||
expect(isBoundaryParamFixed(hydrated, metaP)).toBe(false);
|
||||
});
|
||||
|
||||
it("pairing: Fixed ṁ frees P on source", () => {
|
||||
const { nodes, edges } = waterLoop(
|
||||
{ m_flow_kg_s: 0.55, [fixedFlagKey("p_set_bar")]: true },
|
||||
{ p_back_bar: 3 },
|
||||
);
|
||||
const patches = boundaryFixPairingPatches("s", "m_flow_kg_s", true, nodes, edges);
|
||||
expect(patches.get("s")?.[fixedFlagKey("p_set_bar")]).toBe(false);
|
||||
});
|
||||
|
||||
it("pairing: Fixed P frees ṁ (Boundary_pT)", () => {
|
||||
const { nodes, edges } = waterLoop({ m_flow_kg_s: 0.55 }, { p_back_bar: 3 });
|
||||
const patches = boundaryFixPairingPatches("s", "p_set_bar", true, nodes, edges);
|
||||
expect(patches.get("s")?.[fixedFlagKey("m_flow_kg_s")]).toBe(false);
|
||||
});
|
||||
|
||||
it("detects Fixed P + Fixed ṁ on source", () => {
|
||||
const { nodes, edges } = waterLoop(
|
||||
{
|
||||
m_flow_kg_s: 0.55,
|
||||
[fixedFlagKey("m_flow_kg_s")]: true,
|
||||
[fixedFlagKey("p_set_bar")]: true,
|
||||
},
|
||||
{ p_back_bar: 3, [fixedFlagKey("p_back_bar")]: true },
|
||||
);
|
||||
const conflicts = findSecondaryLoopDofConflicts(nodes, edges);
|
||||
expect(conflicts.some((c) => c.message.includes("MassFlowSource_T"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enforceModelicaBoundaryEmit", () => {
|
||||
it("MassFlowSource emit: Free P + Fixed ṁ", () => {
|
||||
const { nodes, edges } = waterLoop(
|
||||
{
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.55,
|
||||
[fixedFlagKey("m_flow_kg_s")]: true,
|
||||
[fixedFlagKey("p_set_bar")]: true,
|
||||
[fixedFlagKey("t_set_c")]: true,
|
||||
},
|
||||
{ p_back_bar: 3, [fixedFlagKey("p_back_bar")]: true },
|
||||
);
|
||||
const components = nodes.map((n) => ({
|
||||
type: n.data.type,
|
||||
name: n.data.name,
|
||||
...stripUiOnlyParams(n.data.type, n.data.params),
|
||||
})) as Record<string, unknown>[];
|
||||
enforceModelicaBoundaryEmit(components, nodes, edges);
|
||||
const src = components.find((c) => c.name === "src")!;
|
||||
expect(src.fix_mass_flow).toBe(true);
|
||||
expect(src.fix_pressure).toBe(false);
|
||||
});
|
||||
|
||||
it("isobaric + Fixed T_out: Free ṁ and Free P on source", () => {
|
||||
const { nodes, edges } = waterLoop(
|
||||
{
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.55,
|
||||
[fixedFlagKey("m_flow_kg_s")]: true,
|
||||
[fixedFlagKey("p_set_bar")]: true,
|
||||
[fixedFlagKey("t_set_c")]: true,
|
||||
},
|
||||
{
|
||||
p_back_bar: 3,
|
||||
t_set_c: 7,
|
||||
[fixedFlagKey("p_back_bar")]: true,
|
||||
[fixedFlagKey("t_set_c")]: true,
|
||||
},
|
||||
);
|
||||
const rfNodes: Node<EntropykNodeData>[] = nodes.map((n) => ({
|
||||
...n,
|
||||
data: {
|
||||
type: n.data.type,
|
||||
name: n.data.name,
|
||||
circuit: 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params: n.data.params,
|
||||
},
|
||||
}));
|
||||
const cfg = buildScenarioConfig(rfNodes, edges);
|
||||
const src = cfg.circuits[0].components.find((c) => c.name === "src")!;
|
||||
expect(src.fix_mass_flow).toBe(false);
|
||||
expect(src.fix_pressure).toBe(false);
|
||||
});
|
||||
});
|
||||
423
apps/web/src/lib/boundaryFix.ts
Normal file
423
apps/web/src/lib/boundaryFix.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Modelica.Fluid.Sources Fixed/Free — single source of truth.
|
||||
*
|
||||
* PROOF (official MSL + Modelica community):
|
||||
* 1) Package index:
|
||||
* https://doc.modelica.org/om/Modelica.Fluid.Sources.html
|
||||
* — Boundary_pT | Boundary_ph | MassFlowSource_T | MassFlowSource_h
|
||||
* 2) Boundary_pT (prescribes P + T, NOT ṁ):
|
||||
* https://doc.modelica.org/om/Modelica.Fluid.Sources.Boundary_pT.html
|
||||
* 3) MassFlowSource_T (prescribes ṁ + T, NOT P):
|
||||
* https://doc.modelica.org/Modelica%204.0.0/Resources/helpWSM/Modelica/Modelica.Fluid.Sources.MassFlowSource_T.html
|
||||
* 4) MSL example SimplePipeline: Boundary_pT → StaticPipe(ΔP) → Boundary_pT
|
||||
* (Context7 /modelica/modelicastandardlibrary) — double Fixed P ONLY with hydraulic ΔP.
|
||||
* 5) Legal pipe-end combinations (Rene Just Nielsen):
|
||||
* https://stackoverflow.com/questions/79349553/how-pressure-and-flow-ports-are-different-in-modelica
|
||||
* — Boundary_pT+Boundary_pT | Boundary_pT+MassFlowSource | MassFlowSource+Boundary_pT
|
||||
* — ILLEGAL: MassFlowSource + MassFlowSource
|
||||
* — "no boundary component specifying both mass flow rate and pressure"
|
||||
*
|
||||
* Entropyk mapping (secondary water/air branch):
|
||||
* MassFlowSource_T → Source: Fixed T, Fixed ṁ, Free P ; Sink: Fixed P (anchor)
|
||||
* Boundary_pT → Source: Fixed P, Fixed T, Free ṁ ; Sink: Fixed P if ΔP else one P free
|
||||
* Illegal → Fixed ṁ + Fixed P on same Source
|
||||
* → Fixed ṁ + Fixed T_out on partner Sink
|
||||
* → Fixed P both ends + Fixed ṁ (MassFlowSource + double P)
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
COMPONENT_BY_TYPE,
|
||||
FIXED_FLAG_PREFIX,
|
||||
fixedFlagKey,
|
||||
type ParamMeta,
|
||||
} from "./componentMeta";
|
||||
|
||||
export interface BoundaryNodeData {
|
||||
type: string;
|
||||
name: string;
|
||||
params: Record<string, number | string | boolean>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const CLI_FIX_FLAG: Record<string, string> = {
|
||||
p_set_bar: "fix_pressure",
|
||||
p_back_bar: "fix_pressure",
|
||||
t_set_c: "fix_temperature",
|
||||
t_dry_c: "fix_temperature",
|
||||
t_back_c: "fix_temperature",
|
||||
m_flow_kg_s: "fix_mass_flow",
|
||||
};
|
||||
|
||||
export function cliFixFlagForParam(paramKey: string): string | undefined {
|
||||
return CLI_FIX_FLAG[paramKey];
|
||||
}
|
||||
|
||||
export function isBoundaryParamFixed(
|
||||
params: Record<string, number | string | boolean | undefined>,
|
||||
meta: ParamMeta,
|
||||
): boolean {
|
||||
if (!meta.fixable) return true;
|
||||
|
||||
const uiFlag = params[fixedFlagKey(meta.key)];
|
||||
if (uiFlag === true || uiFlag === "true") return true;
|
||||
if (uiFlag === false || uiFlag === "false") return false;
|
||||
|
||||
const cliKey = cliFixFlagForParam(meta.key);
|
||||
if (cliKey && params[cliKey] !== undefined) {
|
||||
const v = params[cliKey];
|
||||
if (v === true || v === "true") return true;
|
||||
if (v === false || v === "false") return false;
|
||||
}
|
||||
|
||||
return meta.defaultFixed !== false;
|
||||
}
|
||||
|
||||
export function hydrateBoundaryFixFlags(
|
||||
type: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
if (!meta) return params;
|
||||
const out: Record<string, number | string | boolean> = { ...params };
|
||||
|
||||
for (const p of meta.params) {
|
||||
if (!p.fixable) continue;
|
||||
const cliKey = cliFixFlagForParam(p.key);
|
||||
if (!cliKey || out[cliKey] === undefined) continue;
|
||||
const v = out[cliKey];
|
||||
out[fixedFlagKey(p.key)] = v === true || v === "true";
|
||||
delete out[cliKey];
|
||||
}
|
||||
|
||||
// Legacy sink: t_set present without flag ⇒ Fixed T_out.
|
||||
if (type === "BrineSink" || type === "AirSink") {
|
||||
const tKey = type === "AirSink" ? "t_back_c" : "t_set_c";
|
||||
const flagKey = fixedFlagKey(tKey);
|
||||
if (out[flagKey] === undefined && out[tKey] !== undefined && out[tKey] !== "") {
|
||||
out[flagKey] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Modelica MassFlowSource_T: cannot Fixed P and Fixed ṁ together.
|
||||
// If imported JSON has ṁ without fix_pressure=false, Free P on source.
|
||||
if (type === "BrineSource" || type === "AirSource") {
|
||||
const mMeta = meta.params.find((p) => p.key === "m_flow_kg_s");
|
||||
const pMeta = meta.params.find((p) => p.key === "p_set_bar");
|
||||
if (mMeta && pMeta && isBoundaryParamFixed(out, mMeta)) {
|
||||
const pFlag = fixedFlagKey("p_set_bar");
|
||||
if (out[pFlag] === undefined && out.fix_pressure === undefined) {
|
||||
// Default MassFlowSource: Free P unless explicitly Fixed without ṁ.
|
||||
out[pFlag] = false;
|
||||
}
|
||||
if (isBoundaryParamFixed(out, mMeta) && isBoundaryParamFixed(out, pMeta)) {
|
||||
out[pFlag] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const SECONDARY_TRAVERSABLE = (type: string): boolean =>
|
||||
type === "BrineSource" ||
|
||||
type === "BrineSink" ||
|
||||
type === "AirSource" ||
|
||||
type === "AirSink" ||
|
||||
type.includes("Pipe") ||
|
||||
type.includes("Pump") ||
|
||||
type.includes("Fan") ||
|
||||
type.includes("Condenser") ||
|
||||
type.includes("Evaporator") ||
|
||||
type.includes("HeatExchanger") ||
|
||||
type.includes("Bphx") ||
|
||||
type === "FloodedEvaporator" ||
|
||||
type === "ThermalLoad";
|
||||
|
||||
/** HX types whose secondary side is typically isobaric (no water ΔP residual). */
|
||||
const ISOBARIC_SECONDARY = (type: string): boolean =>
|
||||
type === "FloodedEvaporator" ||
|
||||
type === "FloodedCondenser" ||
|
||||
(type.includes("Evaporator") && !type.includes("Bphx")) ||
|
||||
(type.includes("Condenser") && type !== "BphxCondenser" && !type.includes("Mchx"));
|
||||
|
||||
export function findConnectedSecondaryBoundary(
|
||||
startId: string,
|
||||
nodes: Node<BoundaryNodeData>[],
|
||||
edges: Edge[],
|
||||
targetType: "BrineSource" | "BrineSink" | "AirSource" | "AirSink",
|
||||
): Node<BoundaryNodeData> | null {
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (!adj.has(e.source)) adj.set(e.source, []);
|
||||
if (!adj.has(e.target)) adj.set(e.target, []);
|
||||
adj.get(e.source)!.push(e.target);
|
||||
adj.get(e.target)!.push(e.source);
|
||||
}
|
||||
const seen = new Set<string>([startId]);
|
||||
const queue = [startId];
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift()!;
|
||||
for (const next of adj.get(id) ?? []) {
|
||||
if (seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
const n = nodeById.get(next);
|
||||
if (!n) continue;
|
||||
if (n.data.type === targetType) return n;
|
||||
if (SECONDARY_TRAVERSABLE(n.data.type)) queue.push(next);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True if path Source→Sink crosses an isobaric secondary HX (no water ΔP). */
|
||||
function pathHasIsobaricSecondary(
|
||||
sourceId: string,
|
||||
sinkId: string,
|
||||
nodes: Node<BoundaryNodeData>[],
|
||||
edges: Edge[],
|
||||
): boolean {
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (!adj.has(e.source)) adj.set(e.source, []);
|
||||
if (!adj.has(e.target)) adj.set(e.target, []);
|
||||
adj.get(e.source)!.push(e.target);
|
||||
adj.get(e.target)!.push(e.source);
|
||||
}
|
||||
const seen = new Set<string>([sourceId]);
|
||||
const queue = [sourceId];
|
||||
let isobaric = false;
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift()!;
|
||||
if (id === sinkId) return isobaric;
|
||||
for (const next of adj.get(id) ?? []) {
|
||||
if (seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
const n = nodeById.get(next);
|
||||
if (!n) continue;
|
||||
if (ISOBARIC_SECONDARY(n.data.type)) isobaric = true;
|
||||
if (SECONDARY_TRAVERSABLE(n.data.type) || n.id === sinkId) queue.push(next);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface SecondaryLoopDofConflict {
|
||||
sourceName: string;
|
||||
sinkName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function findSecondaryLoopDofConflicts(
|
||||
nodes: Node<BoundaryNodeData>[],
|
||||
edges: Edge[],
|
||||
): SecondaryLoopDofConflict[] {
|
||||
const conflicts: SecondaryLoopDofConflict[] = [];
|
||||
|
||||
for (const source of nodes) {
|
||||
if (source.data.type !== "BrineSource" && source.data.type !== "AirSource") continue;
|
||||
const pMeta = COMPONENT_BY_TYPE[source.data.type]?.params.find((x) => x.key === "p_set_bar");
|
||||
const mMeta = COMPONENT_BY_TYPE[source.data.type]?.params.find((x) => x.key === "m_flow_kg_s");
|
||||
if (!pMeta || !mMeta) continue;
|
||||
|
||||
// MassFlowSource cannot Fixed P and Fixed ṁ (Modelica local balance).
|
||||
if (isBoundaryParamFixed(source.data.params, pMeta) && isBoundaryParamFixed(source.data.params, mMeta)) {
|
||||
conflicts.push({
|
||||
sourceName: source.data.name,
|
||||
sinkName: "",
|
||||
message:
|
||||
`${source.data.name}: Fixed P + Fixed ṁ illegal (Modelica MassFlowSource_T ` +
|
||||
`prescribes ṁ+T only — Free P). See Modelica.Fluid.Sources.MassFlowSource_T.`,
|
||||
});
|
||||
}
|
||||
|
||||
const sinkType = source.data.type === "AirSource" ? "AirSink" : "BrineSink";
|
||||
const sink = findConnectedSecondaryBoundary(source.id, nodes, edges, sinkType);
|
||||
if (!sink) continue;
|
||||
|
||||
const sinkP = COMPONENT_BY_TYPE[sinkType]?.params.find((x) => x.key === "p_back_bar");
|
||||
const tKey = sinkType === "AirSink" ? "t_back_c" : "t_set_c";
|
||||
const sinkT = COMPONENT_BY_TYPE[sinkType]?.params.find((x) => x.key === tKey);
|
||||
|
||||
// Fixed ṁ + Fixed T_out
|
||||
if (
|
||||
sinkT &&
|
||||
isBoundaryParamFixed(source.data.params, mMeta) &&
|
||||
isBoundaryParamFixed(sink.data.params, sinkT) &&
|
||||
sink.data.params[tKey] !== undefined &&
|
||||
sink.data.params[tKey] !== ""
|
||||
) {
|
||||
conflicts.push({
|
||||
sourceName: source.data.name,
|
||||
sinkName: sink.data.name,
|
||||
message:
|
||||
`${sink.data.name}: Fixed T_out + ${source.data.name} Fixed ṁ — ` +
|
||||
`use Boundary_pT (Free ṁ on source).`,
|
||||
});
|
||||
}
|
||||
|
||||
// Fixed P both ends + Fixed ṁ, or Fixed P both ends on isobaric secondary
|
||||
if (
|
||||
sinkP &&
|
||||
isBoundaryParamFixed(source.data.params, pMeta) &&
|
||||
isBoundaryParamFixed(sink.data.params, sinkP)
|
||||
) {
|
||||
const mFixed = isBoundaryParamFixed(source.data.params, mMeta);
|
||||
const isobaric = pathHasIsobaricSecondary(source.id, sink.id, nodes, edges);
|
||||
if (mFixed) {
|
||||
conflicts.push({
|
||||
sourceName: source.data.name,
|
||||
sinkName: sink.data.name,
|
||||
message:
|
||||
`${source.data.name}/${sink.data.name}: Fixed P both ends + Fixed ṁ — ` +
|
||||
`Modelica: MassFlowSource_T + Boundary_p (Free P on source, Fixed P on sink only).`,
|
||||
});
|
||||
} else if (isobaric) {
|
||||
conflicts.push({
|
||||
sourceName: source.data.name,
|
||||
sinkName: sink.data.name,
|
||||
message:
|
||||
`${source.data.name}/${sink.data.name}: Fixed P both ends on isobaric secondary — ` +
|
||||
`Modelica allows Boundary_pT×2 only with hydraulic ΔP (pipe/friction). ` +
|
||||
`Free P on source OR add WaterPipe ΔP.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairing patches (atomic store apply).
|
||||
* Modelica rules when toggling Fixed.
|
||||
*/
|
||||
export function boundaryFixPairingPatches(
|
||||
nodeId: string,
|
||||
paramKey: string,
|
||||
fixed: boolean,
|
||||
nodes: Node<BoundaryNodeData>[],
|
||||
edges: Edge[],
|
||||
): Map<string, Record<string, number | string | boolean>> {
|
||||
const patches = new Map<string, Record<string, number | string | boolean>>();
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return patches;
|
||||
|
||||
const self: Record<string, number | string | boolean> = {
|
||||
[fixedFlagKey(paramKey)]: fixed,
|
||||
};
|
||||
patches.set(nodeId, self);
|
||||
|
||||
const isSource = node.data.type === "BrineSource" || node.data.type === "AirSource";
|
||||
const isSink = node.data.type === "BrineSink" || node.data.type === "AirSink";
|
||||
|
||||
// MassFlowSource_T: Fixed ṁ ⇒ Free P on same source (cannot Fixed both).
|
||||
if (isSource && paramKey === "m_flow_kg_s" && fixed) {
|
||||
self[fixedFlagKey("p_set_bar")] = false;
|
||||
const sinkType = node.data.type === "AirSource" ? "AirSink" : "BrineSink";
|
||||
const partner = findConnectedSecondaryBoundary(nodeId, nodes, edges, sinkType);
|
||||
if (partner) {
|
||||
const tKey = sinkType === "AirSink" ? "t_back_c" : "t_set_c";
|
||||
patches.set(partner.id, { [fixedFlagKey(tKey)]: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Boundary_pT on source: Fixed P ⇒ Free ṁ (cannot Fixed both).
|
||||
if (isSource && paramKey === "p_set_bar" && fixed) {
|
||||
self[fixedFlagKey("m_flow_kg_s")] = false;
|
||||
}
|
||||
|
||||
// Fixed T_out on Sink ⇒ Free ṁ on Source (energy closes ṁ).
|
||||
if (isSink && (paramKey === "t_set_c" || paramKey === "t_back_c") && fixed) {
|
||||
const srcType = node.data.type === "AirSink" ? "AirSource" : "BrineSource";
|
||||
const partner = findConnectedSecondaryBoundary(nodeId, nodes, edges, srcType);
|
||||
if (partner) {
|
||||
patches.set(partner.id, {
|
||||
[fixedFlagKey("m_flow_kg_s")]: false,
|
||||
// Boundary_pT mode: keep / restore Fixed P on source when freeing ṁ
|
||||
[fixedFlagKey("p_set_bar")]: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed P on Sink while Source has Fixed ṁ ⇒ ensure Source Free P (MassFlowSource+Boundary_p).
|
||||
if (isSink && paramKey === "p_back_bar" && fixed) {
|
||||
const srcType = node.data.type === "AirSink" ? "AirSource" : "BrineSource";
|
||||
const partner = findConnectedSecondaryBoundary(nodeId, nodes, edges, srcType);
|
||||
if (partner) {
|
||||
const mMeta = COMPONENT_BY_TYPE[srcType]?.params.find((p) => p.key === "m_flow_kg_s");
|
||||
if (mMeta && isBoundaryParamFixed(partner.data.params, mMeta)) {
|
||||
const prev = patches.get(partner.id) ?? {};
|
||||
patches.set(partner.id, { ...prev, [fixedFlagKey("p_set_bar")]: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return patches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit-time compiler: force Modelica-legal fix_* on the JSON sent to CLI.
|
||||
*
|
||||
* MassFlowSource_T: Fixed ṁ + Fixed T + Free P ; Sink Fixed P
|
||||
* Boundary_pT: Fixed P + Fixed T + Free ṁ ; one P-anchor if isobaric
|
||||
*/
|
||||
export function enforceModelicaBoundaryEmit(
|
||||
components: Record<string, unknown>[],
|
||||
nodes: Node<BoundaryNodeData>[],
|
||||
edges: Edge[],
|
||||
): void {
|
||||
const byName = new Map(components.map((c) => [String(c.name), c]));
|
||||
|
||||
for (const sourceNode of nodes) {
|
||||
if (sourceNode.data.type !== "BrineSource" && sourceNode.data.type !== "AirSource") {
|
||||
continue;
|
||||
}
|
||||
const sourceComp = byName.get(sourceNode.data.name);
|
||||
if (!sourceComp) continue;
|
||||
|
||||
const sinkType = sourceNode.data.type === "AirSource" ? "AirSink" : "BrineSink";
|
||||
const sinkNode = findConnectedSecondaryBoundary(sourceNode.id, nodes, edges, sinkType);
|
||||
const sinkComp = sinkNode ? byName.get(sinkNode.data.name) : undefined;
|
||||
const isobaric =
|
||||
!!sinkNode && pathHasIsobaricSecondary(sourceNode.id, sinkNode.id, nodes, edges);
|
||||
|
||||
// Fixed T_out on sink ⇒ Free ṁ (energy closes ṁ)
|
||||
if (sinkComp?.fix_temperature === true) {
|
||||
sourceComp.fix_mass_flow = false;
|
||||
}
|
||||
|
||||
// Never Fixed P and Fixed ṁ on the same source (Modelica local balance)
|
||||
if (sourceComp.fix_mass_flow === true) {
|
||||
sourceComp.fix_pressure = false;
|
||||
}
|
||||
|
||||
// Sink is the pressure anchor when present
|
||||
if (sinkComp) {
|
||||
if (sinkComp.fix_pressure === undefined) sinkComp.fix_pressure = true;
|
||||
|
||||
// Isobaric secondary: only one P Dirichlet (sink). Source Free P.
|
||||
// (Boundary_pT×2 requires hydraulic ΔP — MSL SimplePipeline + StaticPipe.)
|
||||
if (isobaric && sinkComp.fix_pressure !== false) {
|
||||
sourceComp.fix_pressure = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stripCliFixFlags(
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
const out: Record<string, number | string | boolean> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (k === "fix_pressure" || k === "fix_temperature" || k === "fix_mass_flow") continue;
|
||||
if (k.startsWith(FIXED_FLAG_PREFIX)) {
|
||||
out[k] = v;
|
||||
continue;
|
||||
}
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
94
apps/web/src/lib/componentDocMap.ts
Normal file
94
apps/web/src/lib/componentDocMap.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Maps Entropyk component types → docs/components/<slug>.md
|
||||
* Served in the web app from /docs/components/<slug>.md
|
||||
*/
|
||||
|
||||
export const COMPONENT_DOC_SLUG: Record<string, string> = {
|
||||
IsentropicCompressor: "isentropic-compressor",
|
||||
Compressor: "compressor-ahri540",
|
||||
ScrewEconomizerCompressor: "screw-economizer-compressor",
|
||||
ScrewCompressor: "screw-economizer-compressor",
|
||||
Condenser: "condenser",
|
||||
CondenserCoil: "condenser",
|
||||
Evaporator: "evaporator",
|
||||
EvaporatorCoil: "evaporator",
|
||||
FloodedEvaporator: "flooded-evaporator",
|
||||
FloodedCondenser: "flooded-condenser",
|
||||
BphxEvaporator: "bphx",
|
||||
BphxCondenser: "bphx",
|
||||
AirCooledCondenser: "air-cooled-condenser",
|
||||
FinCoilCondenser: "fin-coil-condenser",
|
||||
MchxCondenserCoil: "mchx-condenser-coil",
|
||||
MchxCoil: "mchx-condenser-coil",
|
||||
HeatExchanger: "heat-exchanger-generic",
|
||||
FreeCoolingExchanger: "free-cooling-exchanger",
|
||||
FreeCooling: "free-cooling-exchanger",
|
||||
Economizer: "economizer",
|
||||
MovingBoundaryHX: "moving-boundary-hx",
|
||||
IsenthalpicExpansionValve: "isenthalpic-expansion-valve",
|
||||
EXV: "isenthalpic-expansion-valve",
|
||||
ExpansionValve: "expansion-valve",
|
||||
BypassValve: "bypass-valve",
|
||||
ReversingValve: "reversing-valve",
|
||||
FourWayValve: "reversing-valve",
|
||||
Pipe: "pipe",
|
||||
Drum: "drum",
|
||||
FlowSplitter: "flow-splitter",
|
||||
FlowMerger: "flow-merger",
|
||||
Pump: "pump",
|
||||
Fan: "fan",
|
||||
RefrigerantSource: "boundaries",
|
||||
RefrigerantSink: "boundaries",
|
||||
BrineSource: "boundaries",
|
||||
BrineSink: "boundaries",
|
||||
AirSource: "boundaries",
|
||||
AirSink: "boundaries",
|
||||
Anchor: "anchor-heat-source",
|
||||
RefrigerantNode: "anchor-heat-source",
|
||||
HeatSource: "anchor-heat-source",
|
||||
ThermalLoad: "thermal-load",
|
||||
// Master index of correlations & compressor maps
|
||||
__correlations: "correlations-and-maps",
|
||||
};
|
||||
|
||||
/** Short model formula shown in the parameter panel header. */
|
||||
export function modelBannerForType(type: string): string | undefined {
|
||||
switch (type) {
|
||||
case "IsentropicCompressor":
|
||||
return "Modèle : h_dis = h_suc+(h_is−h_suc)/η_is · ṁ = ρ·V·N·η_vol (émergent)";
|
||||
case "Compressor":
|
||||
return "Ahri540 : ṁ(M1–M2,P,ρ,V,N) · Ẇ(M3–M10) | SstSdt : ṁ,Ẇ = a00+a10·SST+a01·SDT+a11·SST·SDT";
|
||||
case "ScrewEconomizerCompressor":
|
||||
case "ScrewCompressor":
|
||||
return "Carte bilinéaire SST/SDT : ṁ,Ẇ = a00+a10·SST+a01·SDT+a11·SST·SDT";
|
||||
case "BphxEvaporator":
|
||||
case "BphxCondenser":
|
||||
return "Corrélation Longo/Shah → h → UA · résidus ε-NTU";
|
||||
case "Condenser":
|
||||
case "CondenserCoil":
|
||||
return "ε-NTU biphasique : Q = ε·C_sec·(T_cond−T_sec) · UA global";
|
||||
case "Evaporator":
|
||||
case "EvaporatorCoil":
|
||||
return "ε-NTU DX : Q = ε·C_sec·(T_sec−T_evap) · clôture superheat";
|
||||
case "FloodedEvaporator":
|
||||
return "ε-NTU noyé : Q = ε·C_sec·(T_sec−T_evap) · h_out≈h_g(P)";
|
||||
case "IsenthalpicExpansionValve":
|
||||
case "EXV":
|
||||
return "Isenthalpe h_out=h_in · option orifice ṁ=Kv·op·√(2ρΔP)";
|
||||
case "Pump":
|
||||
return "Courbes 1D H(Q), η(Q) + affinité vitesse";
|
||||
case "Fan":
|
||||
return "Courbes 1D ΔP(Q), puissance · affinité vitesse";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function docSlugForType(type: string): string | undefined {
|
||||
return COMPONENT_DOC_SLUG[type];
|
||||
}
|
||||
|
||||
export function docUrlForType(type: string): string | undefined {
|
||||
const slug = docSlugForType(type);
|
||||
return slug ? `/docs/components/${slug}.md` : undefined;
|
||||
}
|
||||
60
apps/web/src/lib/componentInspector.test.ts
Normal file
60
apps/web/src/lib/componentInspector.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import { buildComponentInspector } from "./componentInspector";
|
||||
import type { SimulationResult } from "./api";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, circuit: 0, params: {} },
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildComponentInspector", () => {
|
||||
it("computes ΔP, ṁ and port table from enriched state", () => {
|
||||
const nodes = [
|
||||
node("c", "IsentropicCompressor", "comp"),
|
||||
node("d", "Condenser", "cond"),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: "e0",
|
||||
source: "c",
|
||||
target: "d",
|
||||
sourceHandle: "outlet",
|
||||
targetHandle: "inlet",
|
||||
},
|
||||
];
|
||||
const result: SimulationResult = {
|
||||
input: "t",
|
||||
status: "converged",
|
||||
state: [
|
||||
{
|
||||
edge: 0,
|
||||
pressure_bar: 12.5,
|
||||
enthalpy_kj_kg: 430,
|
||||
mass_flow_kg_s: 0.05,
|
||||
source: "comp",
|
||||
target: "cond",
|
||||
source_port: "outlet",
|
||||
target_port: "inlet",
|
||||
temperature_c: 55,
|
||||
saturation_temperature_c: 48,
|
||||
},
|
||||
],
|
||||
};
|
||||
const insp = buildComponentInspector(nodes[0], nodes, edges, result);
|
||||
expect(insp.title).toBe("comp");
|
||||
expect(insp.ports).toHaveLength(1);
|
||||
expect(insp.ports[0].port).toBe("outlet");
|
||||
expect(insp.ports[0].pressure_bar).toBe(12.5);
|
||||
expect(insp.summaryLines.some((l) => l.includes("ṁ"))).toBe(true);
|
||||
});
|
||||
});
|
||||
224
apps/web/src/lib/componentInspector.ts
Normal file
224
apps/web/src/lib/componentInspector.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Modelica / Dymola-style post-solve variables for a selected component.
|
||||
* Built from diagram wiring + enriched `SimulationResult.state` edges.
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import { COMPONENT_BY_TYPE, isSecondaryPort } from "./componentMeta";
|
||||
import type { SimulationResult } from "./api";
|
||||
|
||||
export interface PortVariable {
|
||||
port: string;
|
||||
role: "in" | "out";
|
||||
stream: "primary" | "secondary" | "other";
|
||||
pressure_bar: number | null;
|
||||
temperature_c: number | null;
|
||||
tsat_c: number | null;
|
||||
enthalpy_kj_kg: number | null;
|
||||
mass_flow_kg_s: number | null;
|
||||
edgeIndex: number;
|
||||
}
|
||||
|
||||
export interface ComponentInspector {
|
||||
title: string;
|
||||
typeLabel: string;
|
||||
type: string;
|
||||
status: string | null;
|
||||
ports: PortVariable[];
|
||||
/** Primary-side pressure drop [bar] (in − out). */
|
||||
delta_p_bar: number | null;
|
||||
/** Secondary-side pressure drop [bar]. */
|
||||
delta_p_sec_bar: number | null;
|
||||
/** m·Δh on primary [kW]. */
|
||||
q_primary_kw: number | null;
|
||||
/** m·Δh on secondary [kW]. */
|
||||
q_secondary_kw: number | null;
|
||||
/** |W| shaft estimate for compressors/fans/pumps [kW]. */
|
||||
work_kw: number | null;
|
||||
superheat_k: number | null;
|
||||
subcooling_k: number | null;
|
||||
summaryLines: string[];
|
||||
}
|
||||
|
||||
type StateEntry = NonNullable<SimulationResult["state"]>[number];
|
||||
|
||||
function streamOf(port: string): PortVariable["stream"] {
|
||||
if (isSecondaryPort(port)) return "secondary";
|
||||
if (/inlet|outlet|suction|discharge/i.test(port)) return "primary";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function roleOf(port: string, isSource: boolean): PortVariable["role"] {
|
||||
if (/outlet|discharge/i.test(port)) return "out";
|
||||
if (/inlet|suction/i.test(port)) return "in";
|
||||
return isSource ? "out" : "in";
|
||||
}
|
||||
|
||||
function findStateForEdge(
|
||||
edgeIndex: number,
|
||||
edge: Edge,
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
state: StateEntry[] | undefined,
|
||||
): StateEntry | undefined {
|
||||
if (!state?.length) return undefined;
|
||||
const byIndex = state[edgeIndex];
|
||||
if (byIndex) return byIndex;
|
||||
|
||||
const src = nodes.find((n) => n.id === edge.source)?.data.name;
|
||||
const tgt = nodes.find((n) => n.id === edge.target)?.data.name;
|
||||
if (!src || !tgt) return undefined;
|
||||
return state.find((s) => s.source === src && s.target === tgt);
|
||||
}
|
||||
|
||||
/** Build Modelica-style variables for the selected component after a solve. */
|
||||
export function buildComponentInspector(
|
||||
node: Node<EntropykNodeData>,
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
result: SimulationResult | null,
|
||||
): ComponentInspector {
|
||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||
const title = node.data.name;
|
||||
const typeLabel = meta?.label ?? node.data.type;
|
||||
const status = result ? String(result.status) : null;
|
||||
const state = result?.state;
|
||||
|
||||
const ports: PortVariable[] = [];
|
||||
edges.forEach((edge, edgeIndex) => {
|
||||
const touches =
|
||||
edge.source === node.id || edge.target === node.id;
|
||||
if (!touches) return;
|
||||
const isSource = edge.source === node.id;
|
||||
const handle = isSource
|
||||
? (edge.sourceHandle ?? "outlet")
|
||||
: (edge.targetHandle ?? "inlet");
|
||||
const entry = findStateForEdge(edgeIndex, edge, nodes, state);
|
||||
const stream = streamOf(handle);
|
||||
// Infer secondary from partner name when handle is plain outlet (BrineSource).
|
||||
const partnerName = isSource
|
||||
? nodes.find((n) => n.id === edge.target)?.data.name ?? ""
|
||||
: nodes.find((n) => n.id === edge.source)?.data.name ?? "";
|
||||
const selfName = node.data.name;
|
||||
const looksWater =
|
||||
stream === "secondary" ||
|
||||
/water|brine|glycol/i.test(`${selfName} ${partnerName}`) ||
|
||||
/Brine|WaterPipe/i.test(node.data.type);
|
||||
const looksAir =
|
||||
/air|duct/i.test(`${selfName} ${partnerName}`) || /AirSource|AirSink|AirDuct/i.test(node.data.type);
|
||||
|
||||
ports.push({
|
||||
port: handle,
|
||||
role: roleOf(handle, isSource),
|
||||
stream: looksAir ? "other" : looksWater ? "secondary" : stream,
|
||||
pressure_bar: entry?.pressure_bar ?? null,
|
||||
temperature_c: entry?.temperature_c ?? null,
|
||||
// Tsat only meaningful on refrigerant — hide for water/air loops.
|
||||
tsat_c:
|
||||
looksWater || looksAir ? null : (entry?.saturation_temperature_c ?? null),
|
||||
enthalpy_kj_kg: entry?.enthalpy_kj_kg ?? null,
|
||||
mass_flow_kg_s: entry?.mass_flow_kg_s ?? null,
|
||||
edgeIndex,
|
||||
});
|
||||
});
|
||||
|
||||
// Prefer meta port order
|
||||
const order = meta?.ports ?? [];
|
||||
ports.sort((a, b) => {
|
||||
const ia = order.indexOf(a.port);
|
||||
const ib = order.indexOf(b.port);
|
||||
if (ia >= 0 && ib >= 0) return ia - ib;
|
||||
if (ia >= 0) return -1;
|
||||
if (ib >= 0) return 1;
|
||||
return a.port.localeCompare(b.port);
|
||||
});
|
||||
|
||||
const primaryIn = ports.find((p) => p.stream === "primary" && p.role === "in");
|
||||
const primaryOut = ports.find((p) => p.stream === "primary" && p.role === "out");
|
||||
const secIn = ports.find((p) => p.stream === "secondary" && p.role === "in");
|
||||
const secOut = ports.find((p) => p.stream === "secondary" && p.role === "out");
|
||||
|
||||
const delta_p_bar =
|
||||
primaryIn?.pressure_bar != null && primaryOut?.pressure_bar != null
|
||||
? primaryIn.pressure_bar - primaryOut.pressure_bar
|
||||
: null;
|
||||
const delta_p_sec_bar =
|
||||
secIn?.pressure_bar != null && secOut?.pressure_bar != null
|
||||
? secIn.pressure_bar - secOut.pressure_bar
|
||||
: null;
|
||||
|
||||
const q_primary_kw = streamDutyKw(primaryIn, primaryOut);
|
||||
const q_secondary_kw = streamDutyKw(secIn, secOut);
|
||||
|
||||
const isMachine = /Compressor|Pump|Fan|Centrifugal/i.test(node.data.type);
|
||||
const work_kw = isMachine && q_primary_kw != null ? Math.abs(q_primary_kw) : null;
|
||||
|
||||
let superheat_k: number | null = null;
|
||||
let subcooling_k: number | null = null;
|
||||
if (
|
||||
primaryOut?.temperature_c != null &&
|
||||
primaryOut.tsat_c != null &&
|
||||
/Evaporator|Flooded|BphxEvap/i.test(node.data.type)
|
||||
) {
|
||||
superheat_k = primaryOut.temperature_c - primaryOut.tsat_c;
|
||||
}
|
||||
if (
|
||||
primaryOut?.temperature_c != null &&
|
||||
primaryOut.tsat_c != null &&
|
||||
/Condenser|BphxCond|GasCooler/i.test(node.data.type)
|
||||
) {
|
||||
subcooling_k = primaryOut.tsat_c - primaryOut.temperature_c;
|
||||
}
|
||||
// Suction superheat at compressor inlet
|
||||
if (
|
||||
primaryIn?.temperature_c != null &&
|
||||
primaryIn.tsat_c != null &&
|
||||
/Compressor/i.test(node.data.type)
|
||||
) {
|
||||
superheat_k = primaryIn.temperature_c - primaryIn.tsat_c;
|
||||
}
|
||||
|
||||
const summaryLines: string[] = [];
|
||||
if (delta_p_bar != null) summaryLines.push(`ΔP = ${fmt(delta_p_bar, 3)} bar`);
|
||||
if (delta_p_sec_bar != null) summaryLines.push(`ΔP sec = ${fmt(delta_p_sec_bar, 3)} bar`);
|
||||
if (q_primary_kw != null) summaryLines.push(`Q̇ = ${fmt(Math.abs(q_primary_kw), 2)} kW`);
|
||||
if (q_secondary_kw != null) summaryLines.push(`Q̇ sec = ${fmt(Math.abs(q_secondary_kw), 2)} kW`);
|
||||
if (work_kw != null) summaryLines.push(`Ẇ = ${fmt(work_kw, 2)} kW`);
|
||||
if (superheat_k != null) summaryLines.push(`SH = ${fmt(superheat_k, 2)} K`);
|
||||
if (subcooling_k != null) summaryLines.push(`SC = ${fmt(subcooling_k, 2)} K`);
|
||||
const m = primaryIn?.mass_flow_kg_s ?? primaryOut?.mass_flow_kg_s;
|
||||
if (m != null) summaryLines.push(`ṁ = ${fmt(m, 4)} kg/s`);
|
||||
|
||||
return {
|
||||
title,
|
||||
typeLabel,
|
||||
type: node.data.type,
|
||||
status,
|
||||
ports,
|
||||
delta_p_bar,
|
||||
delta_p_sec_bar,
|
||||
q_primary_kw,
|
||||
q_secondary_kw,
|
||||
work_kw,
|
||||
superheat_k,
|
||||
subcooling_k,
|
||||
summaryLines,
|
||||
};
|
||||
}
|
||||
|
||||
function streamDutyKw(
|
||||
inn: PortVariable | undefined,
|
||||
out: PortVariable | undefined,
|
||||
): number | null {
|
||||
if (!inn || !out) return null;
|
||||
const m = inn.mass_flow_kg_s ?? out.mass_flow_kg_s;
|
||||
const hIn = inn.enthalpy_kj_kg;
|
||||
const hOut = out.enthalpy_kj_kg;
|
||||
if (m == null || hIn == null || hOut == null) return null;
|
||||
// kW = kg/s · kJ/kg
|
||||
return m * (hOut - hIn);
|
||||
}
|
||||
|
||||
function fmt(v: number, digits: number): string {
|
||||
return Number.isFinite(v) ? v.toFixed(digits) : "—";
|
||||
}
|
||||
213
apps/web/src/lib/componentMeta.test.ts
Normal file
213
apps/web/src/lib/componentMeta.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
COMPONENTS,
|
||||
COMPONENT_BY_TYPE,
|
||||
COMPONENT_CATEGORIES,
|
||||
defaultParams,
|
||||
isSecondaryPort,
|
||||
nodeSize,
|
||||
portLabel,
|
||||
portRole,
|
||||
portSide,
|
||||
} from "./componentMeta";
|
||||
|
||||
describe("portSide", () => {
|
||||
it("places inlet/suction on the left", () => {
|
||||
expect(portSide("inlet")).toBe("left");
|
||||
expect(portSide("inlet_a")).toBe("left");
|
||||
expect(portSide("suction")).toBe("left");
|
||||
});
|
||||
|
||||
it("places outlet/discharge on the right", () => {
|
||||
expect(portSide("outlet")).toBe("right");
|
||||
expect(portSide("outlet_b")).toBe("right");
|
||||
expect(portSide("discharge")).toBe("right");
|
||||
});
|
||||
|
||||
it("places liquid/vapor outlets on the bottom and economizer on top", () => {
|
||||
expect(portSide("liquid_outlet")).toBe("bottom");
|
||||
expect(portSide("vapor_outlet")).toBe("bottom");
|
||||
expect(portSide("economizer")).toBe("top");
|
||||
});
|
||||
|
||||
it("places the caloporteur inlet at the bottom and outlet at the top", () => {
|
||||
expect(portSide("secondary_inlet")).toBe("bottom");
|
||||
expect(portSide("secondary_outlet")).toBe("top");
|
||||
});
|
||||
|
||||
it("defaults unknown ports to the left", () => {
|
||||
expect(portSide("mystery")).toBe("left");
|
||||
});
|
||||
});
|
||||
|
||||
describe("secondary (caloporteur) ports", () => {
|
||||
it("identifies the secondary ports", () => {
|
||||
expect(isSecondaryPort("secondary_inlet")).toBe(true);
|
||||
expect(isSecondaryPort("secondary_outlet")).toBe(true);
|
||||
expect(isSecondaryPort("inlet")).toBe(false);
|
||||
expect(isSecondaryPort("outlet")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats the secondary inlet as a target and outlet as a source", () => {
|
||||
expect(portRole("secondary_inlet")).toBe("target");
|
||||
expect(portRole("secondary_outlet")).toBe("source");
|
||||
});
|
||||
|
||||
it("labels the secondary ports as htf", () => {
|
||||
expect(portLabel("secondary_inlet")).toBe("htf·in");
|
||||
expect(portLabel("secondary_outlet")).toBe("htf·out");
|
||||
});
|
||||
|
||||
it("exposes caloporteur ports on the main heat exchangers", () => {
|
||||
for (const type of [
|
||||
"Condenser",
|
||||
"Evaporator",
|
||||
"FloodedEvaporator",
|
||||
"FinCoilCondenser",
|
||||
"BphxEvaporator",
|
||||
"BphxCondenser",
|
||||
]) {
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
expect(meta.ports, type).toContain("secondary_inlet");
|
||||
expect(meta.ports, type).toContain("secondary_outlet");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("portRole", () => {
|
||||
it("treats outlets/discharge/*_outlet as sources", () => {
|
||||
expect(portRole("outlet")).toBe("source");
|
||||
expect(portRole("discharge")).toBe("source");
|
||||
expect(portRole("liquid_outlet")).toBe("source");
|
||||
expect(portRole("hot_outlet")).toBe("source");
|
||||
});
|
||||
|
||||
it("treats inlets/suction as targets", () => {
|
||||
expect(portRole("inlet")).toBe("target");
|
||||
expect(portRole("suction")).toBe("target");
|
||||
expect(portRole("cold_inlet")).toBe("target");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodeSize", () => {
|
||||
it("gives compressors a square footprint", () => {
|
||||
const s = nodeSize("IsentropicCompressor");
|
||||
expect(s.w).toBe(s.h);
|
||||
});
|
||||
|
||||
it("makes heat exchangers wider than tall", () => {
|
||||
const s = nodeSize("Condenser");
|
||||
expect(s.w).toBeGreaterThan(s.h);
|
||||
});
|
||||
|
||||
it("makes the separator (drum) taller than wide", () => {
|
||||
const s = nodeSize("Drum");
|
||||
expect(s.h).toBeGreaterThan(s.w);
|
||||
});
|
||||
|
||||
it("returns positive dimensions for unknown types", () => {
|
||||
const s = nodeSize("Whatever");
|
||||
expect(s.w).toBeGreaterThan(0);
|
||||
expect(s.h).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("portLabel", () => {
|
||||
it("abbreviates known ports", () => {
|
||||
expect(portLabel("inlet")).toBe("in");
|
||||
expect(portLabel("discharge")).toBe("dis");
|
||||
expect(portLabel("economizer")).toBe("eco");
|
||||
});
|
||||
|
||||
it("falls back to the raw port name", () => {
|
||||
expect(portLabel("custom_port")).toBe("custom_port");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultParams", () => {
|
||||
it("returns the declared default for each param", () => {
|
||||
const params = defaultParams("IsentropicCompressor");
|
||||
expect(params.isentropic_efficiency).toBe(0.75);
|
||||
expect(params.t_cond_k).toBe(323.15);
|
||||
});
|
||||
|
||||
it("returns an empty object for an unknown type", () => {
|
||||
expect(defaultParams("DoesNotExist")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("COMPONENTS catalogue integrity", () => {
|
||||
it("has a unique type for every component", () => {
|
||||
const types = COMPONENTS.map((c) => c.type);
|
||||
expect(new Set(types).size).toBe(types.length);
|
||||
});
|
||||
|
||||
it("indexes every component in COMPONENT_BY_TYPE", () => {
|
||||
for (const c of COMPONENTS) {
|
||||
expect(COMPONENT_BY_TYPE[c.type]).toBe(c);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares at least one port per component", () => {
|
||||
for (const c of COMPONENTS) {
|
||||
if (c.category === "Controls" || c.category === "Advanced") continue;
|
||||
if (c.type === "SaturatedController") continue;
|
||||
expect(c.ports.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses only declared categories", () => {
|
||||
for (const c of COMPONENTS) {
|
||||
expect(COMPONENT_CATEGORIES).toContain(c.category);
|
||||
}
|
||||
});
|
||||
|
||||
it("has unique param keys within each component", () => {
|
||||
for (const c of COMPONENTS) {
|
||||
const keys = c.params.map((p) => p.key);
|
||||
expect(new Set(keys).size, `duplicate param in ${c.type}`).toBe(keys.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses hex colours", () => {
|
||||
for (const c of COMPONENTS) {
|
||||
expect(c.color, c.type).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes detailed fin-coil engineering geometry", () => {
|
||||
const keys = COMPONENT_BY_TYPE.FinCoilCondenser.params.map((param) => param.key);
|
||||
expect(keys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"manufacturer",
|
||||
"model",
|
||||
"face_width_m",
|
||||
"face_height_m",
|
||||
"n_rows",
|
||||
"fin_type",
|
||||
"fin_pitch_fpi",
|
||||
"tube_od_mm",
|
||||
"tube_pitch_mm",
|
||||
"air_face_velocity_m_s",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes manufacturer and plate-pack data for both BPHX models", () => {
|
||||
for (const type of ["BphxEvaporator", "BphxCondenser"]) {
|
||||
const keys = COMPONENT_BY_TYPE[type].params.map((param) => param.key);
|
||||
expect(keys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"manufacturer",
|
||||
"model",
|
||||
"n_plates",
|
||||
"plate_length_m",
|
||||
"plate_width_m",
|
||||
"channel_spacing_mm",
|
||||
"chevron_angle_deg",
|
||||
"correlation",
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
1825
apps/web/src/lib/componentMeta.ts
Normal file
1825
apps/web/src/lib/componentMeta.ts
Normal file
File diff suppressed because it is too large
Load Diff
476
apps/web/src/lib/configBuilder.test.ts
Normal file
476
apps/web/src/lib/configBuilder.test.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
applyBoundaryFixSemantics,
|
||||
buildScenarioConfig,
|
||||
buildFixedFreeCalibrationControls,
|
||||
resolveSecondaryStreams,
|
||||
stripUiOnlyParams,
|
||||
validateConfig,
|
||||
SCHEMA_VERSION,
|
||||
type EntropykNodeData,
|
||||
} from "./configBuilder";
|
||||
import { fixedFlagKey } from "./componentMeta";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
circuit: number,
|
||||
params: Record<string, number | string | boolean> = {},
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, circuit, rotation: 0, params },
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildScenarioConfig", () => {
|
||||
it("groups components by circuit, sorted by id", () => {
|
||||
const nodes = [
|
||||
node("a", "Condenser", "cond", 1, { ua: 5000 }),
|
||||
node("b", "Evaporator", "evap", 0, { ua: 6000 }),
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, []);
|
||||
expect(cfg.circuits.map((c) => c.id)).toEqual([0, 1]);
|
||||
expect(cfg.circuits[0].components[0].name).toBe("evap");
|
||||
expect(cfg.circuits[1].components[0].name).toBe("cond");
|
||||
});
|
||||
|
||||
it("flattens params to the top level of each component", () => {
|
||||
const nodes = [node("a", "Condenser", "cond", 0, { ua: 5000, t_sat_k: 320 })];
|
||||
const cfg = buildScenarioConfig(nodes, []);
|
||||
const comp = cfg.circuits[0].components[0];
|
||||
expect(comp).toMatchObject({ type: "Condenser", name: "cond", ua: 5000, t_sat_k: 320 });
|
||||
});
|
||||
|
||||
it("translates edge handles into name:port references", () => {
|
||||
const nodes = [
|
||||
node("a", "IsentropicCompressor", "comp", 0),
|
||||
node("b", "Condenser", "cond", 0),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "e1", source: "a", target: "b", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
expect(cfg.circuits[0].edges).toEqual([{ from: "comp:outlet", to: "cond:inlet" }]);
|
||||
});
|
||||
|
||||
it("only keeps edges whose endpoints are in the same circuit", () => {
|
||||
const nodes = [
|
||||
node("a", "IsentropicCompressor", "comp", 0),
|
||||
node("b", "Condenser", "cond", 1),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "e1", source: "a", target: "b", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
expect(cfg.circuits[0].edges).toHaveLength(0);
|
||||
expect(cfg.circuits[1].edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("falls back to outlet→inlet when handles are missing", () => {
|
||||
const nodes = [
|
||||
node("a", "IsentropicCompressor", "comp", 0),
|
||||
node("b", "Condenser", "cond", 0),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "e1", source: "a", target: "b", sourceHandle: null, targetHandle: null },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
// Source role → outlet-like; target role → inlet-like
|
||||
expect(cfg.circuits[0].edges[0]).toEqual({ from: "comp:outlet", to: "cond:inlet" });
|
||||
});
|
||||
|
||||
it("applies fluid / backend / solver options and defaults", () => {
|
||||
const cfg = buildScenarioConfig([node("a", "Condenser", "cond", 0)], [], {
|
||||
fluid: "R290",
|
||||
fluidBackend: "Test",
|
||||
maxIterations: 50,
|
||||
});
|
||||
expect(cfg.fluid).toBe("R290");
|
||||
expect(cfg.fluid_backend).toBe("Test");
|
||||
expect(cfg.solver.max_iterations).toBe(50);
|
||||
expect(cfg.solver.tolerance).toBe(1e-6);
|
||||
|
||||
const defaults = buildScenarioConfig([], []);
|
||||
expect(defaults.fluid).toBe("R410A");
|
||||
expect(defaults.fluid_backend).toBe("CoolProp");
|
||||
expect(defaults.solver.strategy).toBe("newton");
|
||||
});
|
||||
|
||||
it("emits the unified IR schema_version", () => {
|
||||
const cfg = buildScenarioConfig([node("a", "Condenser", "cond", 0)], []);
|
||||
expect(cfg.schema_version).toBe(SCHEMA_VERSION);
|
||||
expect(cfg.schema_version).toBe("2");
|
||||
});
|
||||
|
||||
it("passes control loops through into the IR", () => {
|
||||
const controls = [
|
||||
{
|
||||
type: "SaturatedController",
|
||||
id: "cap",
|
||||
measure: { component: "cond", output: "capacity" },
|
||||
actuator: { component: "comp", factor: "f_m", initial: 1.0, min: 0.5, max: 1.5 },
|
||||
target: 7000,
|
||||
gain: 0.01,
|
||||
band: 1.0,
|
||||
},
|
||||
];
|
||||
const cfg = buildScenarioConfig([node("a", "Condenser", "cond", 0)], [], { controls });
|
||||
expect(cfg.controls).toEqual(controls);
|
||||
|
||||
// No controls -> the field is omitted (stays a clean v1-compatible document).
|
||||
const bare = buildScenarioConfig([node("a", "Condenser", "cond", 0)], []);
|
||||
expect(bare.controls).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exports visible controller nodes as controls, not physical components", () => {
|
||||
const nodes = [
|
||||
node("comp", "IsentropicCompressor", "comp", 0, { liquid_injection: true }),
|
||||
node("ctrl", "SaturatedController", "dgt_limiter", 0, {
|
||||
measure_component: "comp",
|
||||
measure_output: "temperature",
|
||||
actuator_component: "comp",
|
||||
actuator_factor: "injection",
|
||||
initial: 0.15,
|
||||
min: 0,
|
||||
max: 0.3,
|
||||
target: 330,
|
||||
gain: -0.5,
|
||||
band: 5,
|
||||
alpha: 0.002,
|
||||
objectives_json: JSON.stringify([
|
||||
{
|
||||
component: "comp",
|
||||
output: "temperature",
|
||||
setpoint: 365,
|
||||
gain: -0.05,
|
||||
combine: "min",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
];
|
||||
|
||||
const cfg = buildScenarioConfig(nodes, []);
|
||||
expect(cfg.circuits[0].components.map((c) => c.type)).toEqual(["IsentropicCompressor"]);
|
||||
expect(cfg.controls).toEqual([
|
||||
{
|
||||
type: "SaturatedController",
|
||||
id: "dgt_limiter",
|
||||
measure: { component: "comp", output: "temperature" },
|
||||
actuator: { component: "comp", factor: "injection", initial: 0.15, min: 0, max: 0.3 },
|
||||
target: 330,
|
||||
gain: -0.5,
|
||||
band: 5,
|
||||
objectives: [
|
||||
{
|
||||
component: "comp",
|
||||
output: "temperature",
|
||||
setpoint: 365,
|
||||
gain: -0.05,
|
||||
combine: "min",
|
||||
},
|
||||
],
|
||||
alpha: 0.002,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateConfig", () => {
|
||||
it("flags an empty diagram", () => {
|
||||
expect(validateConfig([], [])).toContain("Add at least one component.");
|
||||
});
|
||||
|
||||
it("flags duplicate names within a circuit", () => {
|
||||
const nodes = [
|
||||
node("a", "Condenser", "dup", 0),
|
||||
node("b", "Evaporator", "dup", 0),
|
||||
];
|
||||
const issues = validateConfig(nodes, []);
|
||||
expect(issues.some((i) => i.includes("Duplicate component name"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows identical names in different circuits", () => {
|
||||
const nodes = [
|
||||
node("a", "Condenser", "hx", 0),
|
||||
node("b", "Evaporator", "hx", 1),
|
||||
];
|
||||
const issues = validateConfig(nodes, []);
|
||||
expect(issues.some((i) => i.includes("Duplicate"))).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a missing required parameter", () => {
|
||||
// Condenser requires `ua`; omit it.
|
||||
const nodes = [node("a", "Condenser", "cond", 0, { t_sat_k: 320 })];
|
||||
const issues = validateConfig(nodes, []);
|
||||
expect(issues.some((i) => i.includes("required parameter"))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags an unknown component type", () => {
|
||||
const nodes = [node("a", "Nonexistent", "x", 0)];
|
||||
const issues = validateConfig(nodes, []);
|
||||
expect(issues.some((i) => i.includes("Unknown component type"))).toBe(true);
|
||||
});
|
||||
|
||||
it("passes a valid single-component diagram without secondary ports", () => {
|
||||
// Compressors have no secondary ports — pure required-param check.
|
||||
const nodes = [
|
||||
node("a", "IsentropicCompressor", "comp", 0, {
|
||||
isentropic_efficiency: 0.7,
|
||||
t_cond_k: 320,
|
||||
t_evap_k: 280,
|
||||
superheat_k: 5,
|
||||
}),
|
||||
];
|
||||
expect(validateConfig(nodes, [])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("requires live secondary ports OR rating scalars for heat exchangers", () => {
|
||||
const bare = [node("e", "FloodedEvaporator", "evap", 0, { ua: 8000 })];
|
||||
expect(validateConfig(bare, []).some((i) => i.includes("secondary incomplete"))).toBe(true);
|
||||
|
||||
// Rating mode: scalars only, no edges — must be allowed.
|
||||
const rating = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, {
|
||||
ua: 8000,
|
||||
secondary_inlet_temp_c: 12,
|
||||
secondary_mass_flow_kg_s: 0.5,
|
||||
secondary_cp_j_per_kgk: 4180,
|
||||
}),
|
||||
];
|
||||
expect(validateConfig(rating, []).some((i) => i.includes("secondary incomplete"))).toBe(false);
|
||||
|
||||
// System mode: live secondary ports.
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, { ua: 8000 }),
|
||||
node("b", "BrineSource", "brine", 0, { p_set_bar: 2, temperature_c: 10, mass_flow_kg_s: 1.2 }),
|
||||
node("k", "BrineSink", "sink", 0, { p_back_bar: 2 }),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "b", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
{ id: "s2", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
expect(validateConfig(nodes, edges).some((i) => i.includes("secondary incomplete"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("flags quality_control as a DoF risk", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, { ua: 8000, quality_control: true }),
|
||||
node("b", "BrineSource", "brine", 0, { p_set_bar: 2, t_set_c: 10, m_flow_kg_s: 1.2 }),
|
||||
node("k", "BrineSink", "sink", 0, { p_back_bar: 2 }),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "b", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
{ id: "s2", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
expect(validateConfig(nodes, edges).some((i) => i.includes("quality_control"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("boundary Fixed/Free (Modelica-style)", () => {
|
||||
it("emits fix_mass_flow=false when ṁ is Free", () => {
|
||||
const raw = {
|
||||
p_set_bar: 2,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.5,
|
||||
[fixedFlagKey("p_set_bar")]: true,
|
||||
[fixedFlagKey("t_set_c")]: true,
|
||||
[fixedFlagKey("m_flow_kg_s")]: false,
|
||||
};
|
||||
const out = stripUiOnlyParams("BrineSource", raw);
|
||||
expect(out.fix_mass_flow).toBe(false);
|
||||
expect(out.fix_pressure).toBe(true);
|
||||
expect(out.fix_temperature).toBe(true);
|
||||
expect(out.m_flow_kg_s).toBe(0.5);
|
||||
});
|
||||
|
||||
it("omits Free sink T_out so CLI does not impose enthalpy", () => {
|
||||
const raw = {
|
||||
p_back_bar: 2,
|
||||
t_set_c: 7,
|
||||
[fixedFlagKey("p_back_bar")]: true,
|
||||
[fixedFlagKey("t_set_c")]: false,
|
||||
};
|
||||
const out = stripUiOnlyParams("BrineSink", raw);
|
||||
expect(out.t_set_c).toBeUndefined();
|
||||
expect(out.fix_temperature).toBe(false);
|
||||
});
|
||||
|
||||
it("forces Modelica MassFlowSource emit: Free P when Fixed ṁ", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, { ua: 8000 }),
|
||||
node("b", "BrineSource", "brine", 0, {
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.55,
|
||||
[fixedFlagKey("m_flow_kg_s")]: true,
|
||||
[fixedFlagKey("p_set_bar")]: true,
|
||||
}),
|
||||
node("k", "BrineSink", "sink", 0, {
|
||||
p_back_bar: 3,
|
||||
[fixedFlagKey("p_back_bar")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "b", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
{ id: "s2", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
const src = cfg.circuits[0].components.find((c) => c.name === "brine")!;
|
||||
expect(src.fix_mass_flow).toBe(true);
|
||||
expect(src.fix_pressure).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Free ṁ on source without inventing sink T from a source ΔT", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, { ua: 8000 }),
|
||||
node("b", "BrineSource", "brine", 0, {
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.5,
|
||||
[fixedFlagKey("m_flow_kg_s")]: false,
|
||||
}),
|
||||
node("k", "BrineSink", "sink", 0, {
|
||||
p_back_bar: 3,
|
||||
t_set_c: 7,
|
||||
[fixedFlagKey("t_set_c")]: true,
|
||||
}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "b", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
{ id: "s2", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
const sink = cfg.circuits[0].components.find((c) => c.name === "sink")!;
|
||||
const src = cfg.circuits[0].components.find((c) => c.name === "brine")!;
|
||||
expect(src.fix_mass_flow).toBe(false);
|
||||
expect(sink.t_set_c).toBe(7);
|
||||
expect(sink.fix_temperature).toBe(true);
|
||||
expect(src).not.toHaveProperty("delta_t_k");
|
||||
});
|
||||
|
||||
it("applyBoundaryFixSemantics leaves non-boundaries unchanged", () => {
|
||||
const out = applyBoundaryFixSemantics("Condenser", { ua: 1000 }, { ua: 1000 });
|
||||
expect(out).toEqual({ ua: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("caloporteur (secondary stream) resolution", () => {
|
||||
it("does not fold or absorb explicit secondary stream nodes", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, { ua: 8000 }),
|
||||
node("b", "BrineSource", "brine", 0, { temperature_c: 9, mass_flow_kg_s: 1.4 }),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "b", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
];
|
||||
const { overrides, absorbed } = resolveSecondaryStreams(nodes, edges);
|
||||
expect(absorbed.size).toBe(0);
|
||||
expect(overrides.size).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves a secondary sink connected to the exchanger outlet", () => {
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", 0, { ua: 6000 }),
|
||||
node("k", "BrineSink", "sink", 0, {}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const { absorbed } = resolveSecondaryStreams(nodes, edges);
|
||||
expect(absorbed.has("k")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps caloporteur connections in the solver graph as explicit 4-port branches", () => {
|
||||
const nodes = [
|
||||
node("c", "IsentropicCompressor", "comp", 0),
|
||||
node("e", "Evaporator", "evap", 0, { ua: 6000 }),
|
||||
node("b", "BrineSource", "brine", 0, { temperature_c: 11, mass_flow_kg_s: 2 }),
|
||||
node("k", "BrineSink", "sink", 0, {}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "r1", source: "c", target: "e", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||
{ id: "s1", source: "b", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
{ id: "s2", source: "e", target: "k", sourceHandle: "secondary_outlet", targetHandle: "inlet" },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
const circuit = cfg.circuits[0];
|
||||
|
||||
const names = circuit.components.map((c) => c.name);
|
||||
expect(names).toContain("comp");
|
||||
expect(names).toContain("evap");
|
||||
expect(names).toContain("brine");
|
||||
expect(names).toContain("sink");
|
||||
|
||||
expect(circuit.edges).toEqual([
|
||||
{ from: "comp:outlet", to: "evap:inlet" },
|
||||
{ from: "brine:outlet", to: "evap:secondary_inlet" },
|
||||
{ from: "evap:secondary_outlet", to: "sink:inlet" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not absorb non-boundary nodes wired to a secondary port", () => {
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", 0, { ua: 6000 }),
|
||||
node("p", "Pump", "pump", 0, {}),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "s1", source: "p", target: "e", sourceHandle: "outlet", targetHandle: "secondary_inlet" },
|
||||
];
|
||||
const { absorbed, overrides } = resolveSecondaryStreams(nodes, edges);
|
||||
expect(absorbed.has("p")).toBe(false);
|
||||
expect(overrides.has("e")).toBe(false);
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
expect(cfg.circuits[0].components.map((c) => c.name)).toContain("pump");
|
||||
expect(cfg.circuits[0].edges).toEqual([{ from: "pump:outlet", to: "evap:secondary_inlet" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fixed / Free calibration (Dymola-style checkboxes)", () => {
|
||||
it("emits a control when SST is Fixed and Z_UA is Free", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", 0, {
|
||||
ua: 9000,
|
||||
calib_sst_c: 5,
|
||||
z_ua: 1.0,
|
||||
// Fixed ON for SST, Fixed OFF for Z_UA
|
||||
[fixedFlagKey("calib_sst_c")]: true,
|
||||
[fixedFlagKey("z_ua")]: false,
|
||||
}),
|
||||
];
|
||||
const controls = buildFixedFreeCalibrationControls(nodes);
|
||||
expect(controls).toHaveLength(1);
|
||||
expect(controls[0]).toMatchObject({
|
||||
measure: { component: "evap", output: "saturationTemperature" },
|
||||
actuator: { component: "evap", factor: "z_ua" },
|
||||
target: 5 + 273.15,
|
||||
});
|
||||
|
||||
const cfg = buildScenarioConfig(nodes, []);
|
||||
expect(cfg.controls?.length).toBe(1);
|
||||
// UI-only keys stripped from component JSON
|
||||
const comp = cfg.circuits[0].components[0];
|
||||
expect(comp.calib_sst_c).toBeUndefined();
|
||||
expect(comp[fixedFlagKey("z_ua")]).toBeUndefined();
|
||||
expect(comp.z_ua).toBe(1.0);
|
||||
});
|
||||
|
||||
it("emits no control when Z_UA stays Fixed", () => {
|
||||
const nodes = [
|
||||
node("e", "Evaporator", "evap", 0, {
|
||||
ua: 6000,
|
||||
calib_sst_c: 5,
|
||||
z_ua: 1.0,
|
||||
[fixedFlagKey("calib_sst_c")]: true,
|
||||
[fixedFlagKey("z_ua")]: true,
|
||||
}),
|
||||
];
|
||||
expect(buildFixedFreeCalibrationControls(nodes)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
687
apps/web/src/lib/configBuilder.ts
Normal file
687
apps/web/src/lib/configBuilder.ts
Normal file
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* Convert a React Flow graph (nodes + edges) into the ScenarioConfig JSON
|
||||
* expected by the Entropyk CLI / API (crates/cli/src/config.rs).
|
||||
*
|
||||
* ScenarioConfig schema:
|
||||
* {
|
||||
* "fluid": "R410A",
|
||||
* "fluid_backend": "CoolProp",
|
||||
* "circuits": [
|
||||
* {
|
||||
* "id": 0,
|
||||
* "components": [ { "type": "...", "name": "...", ...params } ],
|
||||
* "edges": [ { "from": "comp:outlet", "to": "cond:inlet" } ]
|
||||
* }
|
||||
* ],
|
||||
* "thermal_couplings": [ { "hot_circuit": 0, "cold_circuit": 1, "ua": 6000, "efficiency": 0.95 } ],
|
||||
* "solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 }
|
||||
* }
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
enforceModelicaBoundaryEmit,
|
||||
findConnectedSecondaryBoundary,
|
||||
isBoundaryParamFixed,
|
||||
} from "./boundaryFix";
|
||||
import {
|
||||
COMPONENT_BY_TYPE,
|
||||
FIXED_FLAG_PREFIX,
|
||||
isParamFixed,
|
||||
isSecondaryPort,
|
||||
type ParamMeta,
|
||||
} from "./componentMeta";
|
||||
|
||||
// Re-export for existing imports (PropertiesPanel, dofLedger, tests).
|
||||
export { findConnectedSecondaryBoundary } from "./boundaryFix";
|
||||
|
||||
export interface EntropykNodeData {
|
||||
type: string; // Entropyk component type ("Condenser", ...)
|
||||
name: string; // unique name within circuit
|
||||
circuit: number;
|
||||
params: Record<string, number | string | boolean>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ControlConfig {
|
||||
type?: string;
|
||||
id: string;
|
||||
measure: { component: string; output: string };
|
||||
actuator: { component: string; factor: string; initial?: number; min: number; max: number };
|
||||
target: number;
|
||||
gain?: number;
|
||||
band?: number;
|
||||
smooth_eps?: number;
|
||||
objectives?: ControlObjectiveConfig[];
|
||||
alpha?: number;
|
||||
}
|
||||
|
||||
export interface ControlObjectiveConfig {
|
||||
component: string;
|
||||
output: string;
|
||||
setpoint: number;
|
||||
gain: number;
|
||||
combine: "min" | "max";
|
||||
}
|
||||
|
||||
export interface SubsystemTemplate {
|
||||
params?: Record<string, number | string | boolean>;
|
||||
components: Array<Record<string, unknown>>;
|
||||
edges?: Array<{ from: string; to: string }>;
|
||||
ports?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface InstanceConfig {
|
||||
of: string;
|
||||
name: string;
|
||||
circuit?: number;
|
||||
params?: Record<string, number | string | boolean>;
|
||||
}
|
||||
|
||||
export interface ScenarioConfig {
|
||||
/**
|
||||
* Model IR schema version. "1" is the legacy flat circuits/components/edges
|
||||
* graph; "2" adds controls/subsystems/instances/connections. The web UI emits
|
||||
* the current version so the CLI and every consumer read one unified IR.
|
||||
*/
|
||||
schema_version?: string;
|
||||
name?: string;
|
||||
fluid: string;
|
||||
fluid_backend?: string;
|
||||
circuits: Array<{
|
||||
id: number;
|
||||
name?: string;
|
||||
components: Array<Record<string, unknown>>;
|
||||
edges: Array<{ from: string; to: string }>;
|
||||
}>;
|
||||
thermal_couplings?: Array<{
|
||||
hot_circuit: number;
|
||||
cold_circuit: number;
|
||||
ua: number;
|
||||
efficiency: number;
|
||||
}>;
|
||||
/** Steady-state control loops (co-solved). Mirrors crates/cli config `controls`. */
|
||||
controls?: ControlConfig[];
|
||||
/** Reusable subsystem templates (flattened by the CLI at load time). */
|
||||
subsystems?: Record<string, SubsystemTemplate>;
|
||||
/** Template instantiations. */
|
||||
instances?: InstanceConfig[];
|
||||
/** External connections between instance ports. */
|
||||
connections?: Array<{ from: string; to: string }>;
|
||||
solver: {
|
||||
strategy: string;
|
||||
max_iterations: number;
|
||||
tolerance: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** The Model IR schema version emitted by this UI build (kept in sync with the CLI). */
|
||||
export const SCHEMA_VERSION = "2";
|
||||
export const CONTROL_NODE_TYPE = "SaturatedController";
|
||||
|
||||
export interface BuildOptions {
|
||||
fluid?: string;
|
||||
fluidBackend?: string;
|
||||
solverStrategy?: string;
|
||||
maxIterations?: number;
|
||||
tolerance?: number;
|
||||
thermalCouplings?: Array<{
|
||||
hot_circuit: number;
|
||||
cold_circuit: number;
|
||||
ua: number;
|
||||
efficiency: number;
|
||||
}>;
|
||||
/** Steady-state control loops to co-solve (emitted verbatim into the IR). */
|
||||
controls?: ControlConfig[];
|
||||
}
|
||||
|
||||
const PARAM_ALIASES: Record<string, string[]> = {
|
||||
t_set_c: ["temperature_c", "T"],
|
||||
m_flow_kg_s: ["mass_flow_kg_s", "mass_flow"],
|
||||
rh: ["relative_humidity"],
|
||||
p_set_bar: ["pressure_bar"],
|
||||
p_back_bar: ["pressure_bar"],
|
||||
};
|
||||
|
||||
function getParam(
|
||||
params: Record<string, number | string | boolean>,
|
||||
key: string,
|
||||
): number | string | boolean | undefined {
|
||||
if (params[key] !== undefined) return params[key];
|
||||
for (const alias of PARAM_ALIASES[key] ?? []) {
|
||||
if (params[alias] !== undefined) return params[alias];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function canonicalizeParams(
|
||||
type: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
const next = { ...params };
|
||||
if (type === "BrineSource") {
|
||||
const t = getParam(next, "t_set_c");
|
||||
const m = getParam(next, "m_flow_kg_s");
|
||||
const p = getParam(next, "p_set_bar");
|
||||
if (t !== undefined) next.t_set_c = t;
|
||||
if (m !== undefined) next.m_flow_kg_s = m;
|
||||
if (p !== undefined) next.p_set_bar = p;
|
||||
}
|
||||
if (type === "AirSource") {
|
||||
const t = getParam(next, "t_dry_c") ?? getParam(next, "t_set_c");
|
||||
const m = getParam(next, "m_flow_kg_s");
|
||||
const p = getParam(next, "p_set_bar");
|
||||
const rh = getParam(next, "rh");
|
||||
if (t !== undefined) next.t_dry_c = t;
|
||||
if (m !== undefined) next.m_flow_kg_s = m;
|
||||
if (p !== undefined) next.p_set_bar = p;
|
||||
if (rh !== undefined) next.rh = rh;
|
||||
}
|
||||
if (type === "BrineSink" || type === "AirSink" || type === "RefrigerantSink") {
|
||||
const p = getParam(next, "p_back_bar");
|
||||
if (p !== undefined) next.p_back_bar = p;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each React Flow edge carries the source/target port id on its handle.
|
||||
* The CLI expects "componentName:port" strings, so we translate handle ids
|
||||
* (which are port names like "outlet") into "name:port".
|
||||
*
|
||||
* When the handle is missing, fall back by role: sources use an outlet-like
|
||||
* port, targets an inlet-like port — never both ends as `ports[0]` (inlet),
|
||||
* which breaks pipe splice / manual wires.
|
||||
*/
|
||||
function edgeRef(
|
||||
node: Node<EntropykNodeData> | undefined,
|
||||
handleId: string | null | undefined,
|
||||
role: "source" | "target" = "target",
|
||||
): string {
|
||||
if (!node) return "";
|
||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||
const ports = meta?.ports ?? [];
|
||||
if (handleId && ports.includes(handleId)) {
|
||||
return `${node.data.name}:${handleId}`;
|
||||
}
|
||||
const port =
|
||||
role === "source"
|
||||
? ports.find((p) => /outlet|discharge|out$/i.test(p)) ??
|
||||
ports[ports.length - 1] ??
|
||||
"outlet"
|
||||
: ports.find((p) => /inlet|suction|^in$/i.test(p)) ?? ports[0] ?? "inlet";
|
||||
return `${node.data.name}:${port}`;
|
||||
}
|
||||
|
||||
/** A boundary node (Source/Sink) supplies/absorbs a secondary stream. */
|
||||
function isBoundaryNode(node: Node<EntropykNodeData> | undefined): boolean {
|
||||
return !!node && /(?:Source|Sink)$/.test(node.data.type);
|
||||
}
|
||||
|
||||
export interface SecondaryResolution {
|
||||
/** Legacy compatibility: secondary streams are no longer reduced into hidden params. */
|
||||
overrides: Map<string, Record<string, number>>;
|
||||
/** Legacy compatibility: boundary nodes are preserved as explicit solver components. */
|
||||
absorbed: Set<string>;
|
||||
}
|
||||
|
||||
export function resolveSecondaryStreams(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
): SecondaryResolution {
|
||||
void nodes;
|
||||
void edges;
|
||||
return { overrides: new Map(), absorbed: new Set() };
|
||||
}
|
||||
|
||||
export function buildScenarioConfig(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
options: BuildOptions = {},
|
||||
): ScenarioConfig {
|
||||
// Group nodes by circuit id.
|
||||
const circuitsMap = new Map<number, Node<EntropykNodeData>[]>();
|
||||
for (const n of nodes) {
|
||||
const c = n.data?.circuit ?? 0;
|
||||
if (!circuitsMap.has(c)) circuitsMap.set(c, []);
|
||||
circuitsMap.get(c)!.push(n);
|
||||
}
|
||||
|
||||
const nodeById = new Map<string, Node<EntropykNodeData>>();
|
||||
for (const n of nodes) nodeById.set(n.id, n);
|
||||
|
||||
const circuits = Array.from(circuitsMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([circuitId, cNodes]) => {
|
||||
const components = cNodes
|
||||
.filter((n) => n.data.type !== CONTROL_NODE_TYPE)
|
||||
.map((n) => {
|
||||
const { type, name, params } = n.data;
|
||||
const canonicalParams = canonicalizeParams(type, params);
|
||||
const cleaned = stripUiOnlyParams(type, canonicalParams);
|
||||
// The CLI flattens unknown keys as params, so we spread them at top level.
|
||||
return { type, name, ...cleaned } as Record<string, unknown>;
|
||||
});
|
||||
|
||||
// Solver edges: both endpoints in this circuit. Refrigerant and
|
||||
// caloporteur branches are preserved explicitly; heat exchangers are
|
||||
// real 4-port components, not reduced to hidden secondary parameters.
|
||||
const circuitEdges = edges.filter((e) => {
|
||||
const s = nodeById.get(e.source);
|
||||
const t = nodeById.get(e.target);
|
||||
if (s?.data?.circuit !== circuitId || t?.data?.circuit !== circuitId) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const edgeConfigs = circuitEdges.map((e) => ({
|
||||
from: edgeRef(nodeById.get(e.source), e.sourceHandle, "source"),
|
||||
to: edgeRef(nodeById.get(e.target), e.targetHandle, "target"),
|
||||
}));
|
||||
|
||||
enforceModelicaBoundaryEmit(components, cNodes, circuitEdges);
|
||||
|
||||
return { id: circuitId, name: `Circuit ${circuitId}`, components, edges: edgeConfigs };
|
||||
});
|
||||
|
||||
const nodeControls = nodes
|
||||
.filter((node) => node.data.type === CONTROL_NODE_TYPE)
|
||||
.map(controlNodeToConfig);
|
||||
// Dymola/EES Fixed checkboxes → inverse calib pairs (FIX measure + FREE z_*)
|
||||
const fixedFreeControls = buildFixedFreeCalibrationControls(nodes);
|
||||
const controls = mergeControls(
|
||||
mergeControls(options.controls ?? [], nodeControls),
|
||||
fixedFreeControls,
|
||||
);
|
||||
|
||||
return {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
fluid: options.fluid || "R410A",
|
||||
fluid_backend: options.fluidBackend || "CoolProp",
|
||||
circuits,
|
||||
thermal_couplings: options.thermalCouplings || [],
|
||||
...(controls.length > 0 ? { controls } : {}),
|
||||
solver: {
|
||||
strategy: options.solverStrategy || "newton",
|
||||
max_iterations: options.maxIterations ?? 300,
|
||||
tolerance: options.tolerance ?? 1e-6,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove UI-only keys before sending to the CLI:
|
||||
* - `__fixed_*` Fixed checkbox flags
|
||||
* - measure-only calib targets (calib_sst_c, …) — they become control setpoints
|
||||
* - emit Modelica-style `fix_pressure` / `fix_temperature` / `fix_mass_flow`
|
||||
*/
|
||||
export function stripUiOnlyParams(
|
||||
type: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
const measureOnly = new Set(
|
||||
(meta?.params ?? [])
|
||||
.filter((p) => p.measureOutput && !p.actuatorFactor)
|
||||
.map((p) => p.key),
|
||||
);
|
||||
const out: Record<string, number | string | boolean> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (k.startsWith(FIXED_FLAG_PREFIX)) continue;
|
||||
if (measureOnly.has(k)) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return applyBoundaryFixSemantics(type, out, params);
|
||||
}
|
||||
|
||||
const BOUNDARY_FIX_TYPES = new Set([
|
||||
"BrineSource",
|
||||
"BrineSink",
|
||||
"AirSource",
|
||||
"AirSink",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Translate UI Fixed checkboxes into CLI `fix_*` flags for boundary nodes.
|
||||
* Free sink temperatures omit the Dirichlet key so legacy configs stay valid.
|
||||
*/
|
||||
export function applyBoundaryFixSemantics(
|
||||
type: string,
|
||||
cleaned: Record<string, number | string | boolean>,
|
||||
rawParams: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
if (!BOUNDARY_FIX_TYPES.has(type)) return cleaned;
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
if (!meta) return cleaned;
|
||||
const out = { ...cleaned };
|
||||
|
||||
const pMeta = meta.params.find((p) => p.key === "p_set_bar" || p.key === "p_back_bar");
|
||||
const tMeta = meta.params.find(
|
||||
(p) => p.key === "t_set_c" || p.key === "t_dry_c" || p.key === "t_back_c",
|
||||
);
|
||||
const mMeta = meta.params.find((p) => p.key === "m_flow_kg_s");
|
||||
|
||||
if (pMeta?.fixable) {
|
||||
out.fix_pressure = isBoundaryParamFixed(rawParams, pMeta);
|
||||
}
|
||||
if (tMeta?.fixable) {
|
||||
const fixedT = isBoundaryParamFixed(rawParams, tMeta);
|
||||
out.fix_temperature = fixedT;
|
||||
// Free T on sinks: omit the setpoint so CLI does not impose h (legacy presence rule).
|
||||
if (!fixedT && (type === "BrineSink" || type === "AirSink")) {
|
||||
delete out.t_set_c;
|
||||
delete out.t_back_c;
|
||||
}
|
||||
}
|
||||
if (mMeta?.fixable) {
|
||||
out.fix_mass_flow = isBoundaryParamFixed(rawParams, mMeta);
|
||||
}
|
||||
|
||||
delete out.delta_t_k;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build inverse-calibration controls from Fixed checkboxes (EES/Dymola style).
|
||||
*
|
||||
* - Param with `measureOutput` + Fixed ON → impose that measure (setpoint = value)
|
||||
* - Param with `actuatorFactor` + Fixed OFF → free that Z-factor
|
||||
* Pairs on the same component: each free factor with the first fixed measure.
|
||||
*/
|
||||
export function buildFixedFreeCalibrationControls(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
): ControlConfig[] {
|
||||
const controls: ControlConfig[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.data.type === CONTROL_NODE_TYPE) continue;
|
||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||
if (!meta) continue;
|
||||
const params = node.data.params ?? {};
|
||||
|
||||
type Measure = { output: string; target: number; key: string };
|
||||
type FreeAct = { factor: string; initial: number; min: number; max: number; key: string };
|
||||
|
||||
const measures: Measure[] = [];
|
||||
const freeActs: FreeAct[] = [];
|
||||
|
||||
for (const p of meta.params) {
|
||||
if (!p.fixable) continue;
|
||||
const fixed = isParamFixed(params, p);
|
||||
const raw = params[p.key];
|
||||
|
||||
if (p.measureOutput && fixed) {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
if (!Number.isFinite(n)) continue;
|
||||
measures.push({
|
||||
output: p.measureOutput,
|
||||
target: measureSetpointSi(p, n),
|
||||
key: p.key,
|
||||
});
|
||||
}
|
||||
|
||||
if (p.actuatorFactor && !fixed) {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
const initial = Number.isFinite(n) ? n : 1.0;
|
||||
freeActs.push({
|
||||
factor: p.actuatorFactor,
|
||||
initial,
|
||||
min: p.freeMin ?? 0.1,
|
||||
max: p.freeMax ?? 3.0,
|
||||
key: p.key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (freeActs.length === 0 || measures.length === 0) continue;
|
||||
|
||||
for (let i = 0; i < freeActs.length; i++) {
|
||||
const act = freeActs[i];
|
||||
const meas = measures[Math.min(i, measures.length - 1)];
|
||||
controls.push({
|
||||
type: "SaturatedController",
|
||||
id: `calib_${node.data.name}_${act.factor}`,
|
||||
measure: {
|
||||
component: node.data.name,
|
||||
output: meas.output,
|
||||
},
|
||||
actuator: {
|
||||
component: node.data.name,
|
||||
factor: act.factor,
|
||||
initial: act.initial,
|
||||
min: act.min,
|
||||
max: act.max,
|
||||
},
|
||||
target: meas.target,
|
||||
// Negative gain: higher Z_UA → higher capacity / often higher T_sat for flooded
|
||||
// — user can refine; default chosen for UA-style calibration.
|
||||
gain: -0.5,
|
||||
band: 2.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return controls;
|
||||
}
|
||||
|
||||
/** Convert UI measure value to SI expected by the solver (temps → K). */
|
||||
function measureSetpointSi(meta: ParamMeta, value: number): number {
|
||||
const unit = (meta.unit ?? "").toLowerCase();
|
||||
if (unit === "°c" || unit === "c" || meta.key.endsWith("_c")) {
|
||||
return value + 273.15;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function controlNodeToConfig(node: Node<EntropykNodeData>): ControlConfig {
|
||||
const p = node.data.params;
|
||||
const cfg: ControlConfig = {
|
||||
type: "SaturatedController",
|
||||
id: node.data.name,
|
||||
measure: {
|
||||
component: stringParam(p.measure_component, "comp"),
|
||||
output: stringParam(p.measure_output, "temperature"),
|
||||
},
|
||||
actuator: {
|
||||
component: stringParam(p.actuator_component, "comp"),
|
||||
factor: stringParam(p.actuator_factor, "injection"),
|
||||
initial: numberParam(p.initial, 0.15),
|
||||
min: numberParam(p.min, 0.0),
|
||||
max: numberParam(p.max, 0.3),
|
||||
},
|
||||
target: numberParam(p.target, 330.0),
|
||||
gain: numberParam(p.gain, -0.5),
|
||||
band: numberParam(p.band, 5.0),
|
||||
};
|
||||
if (typeof p.smooth_eps === "number" && Number.isFinite(p.smooth_eps)) {
|
||||
cfg.smooth_eps = p.smooth_eps;
|
||||
}
|
||||
const objectives = parseControlObjectives(p.objectives_json);
|
||||
if (objectives.length > 0) cfg.objectives = objectives;
|
||||
if (typeof p.alpha === "number" && Number.isFinite(p.alpha) && p.alpha > 0) {
|
||||
cfg.alpha = p.alpha;
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
export function parseControlObjectives(value: unknown): ControlObjectiveConfig[] {
|
||||
if (typeof value !== "string" || value.trim() === "") return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.flatMap((objective): ControlObjectiveConfig[] => {
|
||||
if (!objective || typeof objective !== "object") return [];
|
||||
const candidate = objective as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.component !== "string" ||
|
||||
typeof candidate.output !== "string" ||
|
||||
typeof candidate.setpoint !== "number" ||
|
||||
!Number.isFinite(candidate.setpoint) ||
|
||||
typeof candidate.gain !== "number" ||
|
||||
!Number.isFinite(candidate.gain) ||
|
||||
(candidate.combine !== "min" && candidate.combine !== "max")
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
component: candidate.component,
|
||||
output: candidate.output,
|
||||
setpoint: candidate.setpoint,
|
||||
gain: candidate.gain,
|
||||
combine: candidate.combine,
|
||||
}];
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function mergeControls(base: ControlConfig[], fromNodes: ControlConfig[]): ControlConfig[] {
|
||||
const merged = new Map<string, ControlConfig>();
|
||||
for (const control of base) merged.set(control.id, control);
|
||||
for (const control of fromNodes) merged.set(control.id, control);
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function stringParam(value: unknown, fallback: string): string {
|
||||
return typeof value === "string" && value.trim() ? value : fallback;
|
||||
}
|
||||
|
||||
function numberParam(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/** Validate the built config — returns a list of human-readable issues. */
|
||||
export function validateConfig(nodes: Node<EntropykNodeData>[], edges: Edge[]): string[] {
|
||||
const issues: string[] = [];
|
||||
|
||||
if (nodes.length === 0) {
|
||||
issues.push("Add at least one component.");
|
||||
}
|
||||
|
||||
// Each circuit must have at least one component.
|
||||
const circuits = new Set(nodes.map((n) => n.data?.circuit ?? 0));
|
||||
for (const c of circuits) {
|
||||
const cNodes = nodes.filter((n) => (n.data?.circuit ?? 0) === c);
|
||||
if (cNodes.length === 0) issues.push(`Circuit ${c} is empty.`);
|
||||
}
|
||||
|
||||
// Duplicate names within a circuit.
|
||||
for (const c of circuits) {
|
||||
const names = nodes
|
||||
.filter((n) => (n.data?.circuit ?? 0) === c)
|
||||
.map((n) => n.data.name);
|
||||
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
||||
if (dupes.length > 0) issues.push(`Duplicate component name(s) in circuit ${c}: ${[...new Set(dupes)].join(", ")}`);
|
||||
}
|
||||
|
||||
// Required params present.
|
||||
for (const n of nodes) {
|
||||
const meta = COMPONENT_BY_TYPE[n.data.type];
|
||||
if (!meta) {
|
||||
issues.push(`Unknown component type "${n.data.type}".`);
|
||||
continue;
|
||||
}
|
||||
for (const p of meta.params) {
|
||||
const params = canonicalizeParams(n.data.type, n.data.params);
|
||||
const supplied = params[p.key] !== undefined && params[p.key] !== "";
|
||||
const fromSecondary = secondaryParamSuppliedByConnection(n, p.key, nodes, edges);
|
||||
// Free fixable params are not required (value is only an initial hint).
|
||||
const needFixed = BOUNDARY_FIX_TYPES.has(n.data.type)
|
||||
? isBoundaryParamFixed(n.data.params, p)
|
||||
: !p.fixable || isParamFixed(n.data.params, p);
|
||||
if (p.required && needFixed && !supplied && !fromSecondary) {
|
||||
issues.push(`${n.data.name}: required parameter "${p.label}" is missing.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Dual-mode HX secondary:
|
||||
// system → both secondary_inlet + secondary_outlet wired (live edges)
|
||||
// rating → scalar T_sec + C_sec (or ṁ·cp) without live edges
|
||||
if (meta.ports.some(isSecondaryPort)) {
|
||||
const connected = new Set<string>();
|
||||
for (const e of edges) {
|
||||
if (e.source === n.id && e.sourceHandle) connected.add(e.sourceHandle);
|
||||
if (e.target === n.id && e.targetHandle) connected.add(e.targetHandle);
|
||||
}
|
||||
const hasIn = connected.has("secondary_inlet");
|
||||
const hasOut = connected.has("secondary_outlet");
|
||||
const liveOk = hasIn && hasOut;
|
||||
const params = canonicalizeParams(n.data.type, n.data.params);
|
||||
const ratingOk = hasRatingSecondaryScalars(params);
|
||||
if (!liveOk && !ratingOk) {
|
||||
issues.push(
|
||||
`${n.data.name}: secondary incomplete — wire secondary_inlet + secondary_outlet ` +
|
||||
`(system mode) OR set rating scalars (secondary_inlet_temp_c + mass flow/cp).`,
|
||||
);
|
||||
} else if ((hasIn || hasOut) && !liveOk) {
|
||||
issues.push(
|
||||
`${n.data.name}: secondary ports partial (need both secondary_inlet and secondary_outlet).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
n.data.type === "FloodedEvaporator" &&
|
||||
(n.data.params?.quality_control === true || n.data.params?.quality_control === "true")
|
||||
) {
|
||||
issues.push(
|
||||
`${n.data.name}: quality_control=true adds +1 FIX residual — free an actuator (EXV/level) ` +
|
||||
`or leave it off for compressor suction models.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Modelica boundary conflicts are reported in the DoF ledger; emit-time
|
||||
// `enforceModelicaBoundaryEmit` auto-corrects legalizable cases (Free P on
|
||||
// MassFlowSource, Free ṁ when Fixed T_out). Hard-block only if emit cannot help.
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function secondaryParamSuppliedByConnection(
|
||||
node: Node<EntropykNodeData>,
|
||||
key: string,
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
): boolean {
|
||||
if (key !== "secondary_inlet_temp_c" && key !== "secondary_mass_flow_kg_s") return false;
|
||||
const sourceEdge = edges.find((edge) => edge.target === node.id && edge.targetHandle === "secondary_inlet");
|
||||
if (!sourceEdge) return false;
|
||||
const source = nodes.find((candidate) => candidate.id === sourceEdge.source);
|
||||
if (!source || !isBoundaryNode(source)) return false;
|
||||
const params = canonicalizeParams(source.data.type, source.data.params);
|
||||
if (key === "secondary_inlet_temp_c") return params.t_set_c !== undefined || params.t_dry_c !== undefined;
|
||||
return params.m_flow_kg_s !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rating-mode secondary stream is complete when T_sec,in and a positive capacity
|
||||
* rate are available: either C_sec directly, or ṁ·cp (with default cp assumed if
|
||||
* only mass flow is set — matches CLI `parse_secondary_stream` defaults).
|
||||
*/
|
||||
function hasRatingSecondaryScalars(
|
||||
params: Record<string, number | string | boolean | undefined>,
|
||||
): boolean {
|
||||
const t =
|
||||
numParam(params.secondary_inlet_temp_c) ?? numParam(params.secondary_inlet_temp_k);
|
||||
if (t === undefined) return false;
|
||||
|
||||
const cDirect = numParam(params.secondary_capacity_rate_w_per_k);
|
||||
if (cDirect !== undefined && cDirect > 0) return true;
|
||||
|
||||
const m = numParam(params.secondary_mass_flow_kg_s);
|
||||
if (m === undefined || m <= 0) return false;
|
||||
const cp = numParam(params.secondary_cp_j_per_kgk);
|
||||
// CLI supplies a fluid-dependent default cp when only mass flow is given.
|
||||
return cp === undefined || cp > 0;
|
||||
}
|
||||
|
||||
function numParam(v: number | string | boolean | undefined): number | undefined {
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (typeof v === "string" && v.trim() !== "") {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
65
apps/web/src/lib/dofCoach.test.ts
Normal file
65
apps/web/src/lib/dofCoach.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import { buildDofCoach } from "./dofCoach";
|
||||
import { hxFamily, hxGlyphKey } from "./hxFamily";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
params: EntropykNodeData["params"] = {},
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropyk",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name: id, circuit: 1, params },
|
||||
};
|
||||
}
|
||||
|
||||
describe("hxFamily", () => {
|
||||
it("distinguishes DX / flooded / plate / coil / MCHX", () => {
|
||||
expect(hxFamily("Evaporator")?.badge).toBe("DX");
|
||||
expect(hxFamily("FloodedEvaporator")?.badge).toBe("Noyé");
|
||||
expect(hxFamily("BphxCondenser")?.badge).toBe("Plaques");
|
||||
expect(hxFamily("AirCooledCondenser")?.badge).toBe("Coil");
|
||||
expect(hxFamily("MchxCondenser")?.badge).toBe("MCHX");
|
||||
expect(hxFamily("Condenser")?.badge).toBe("Tubes");
|
||||
expect(hxGlyphKey("FloodedEvaporator")).toBe("hx_flooded");
|
||||
expect(hxGlyphKey("BphxEvaporator")).toBe("hx_plate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDofCoach", () => {
|
||||
it("guides empty canvas", () => {
|
||||
const coach = buildDofCoach([], []);
|
||||
expect(coach.balance).toBe("empty");
|
||||
expect(coach.tips[0]?.id).toBe("empty");
|
||||
});
|
||||
|
||||
it("flags Fixed P + Fixed ṁ on MassFlowSource-style brine source", () => {
|
||||
const nodes = [
|
||||
node("src", "BrineSource", {
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 1.2,
|
||||
fix_pressure: true,
|
||||
fix_mass_flow: true,
|
||||
}),
|
||||
];
|
||||
const coach = buildDofCoach(nodes, []);
|
||||
expect(coach.tips.some((t) => t.id.startsWith("mflow-p"))).toBe(true);
|
||||
});
|
||||
|
||||
it("suggests wiring secondary when HX has no secondary edges", () => {
|
||||
const nodes = [
|
||||
node("comp", "ScrollCompressor", {}),
|
||||
node("cond", "Condenser", { ua: 1000 }),
|
||||
node("exv", "ExpansionValve", {}),
|
||||
node("evap", "Evaporator", { ua: 1000 }),
|
||||
];
|
||||
const edges: Edge[] = [];
|
||||
const coach = buildDofCoach(nodes, edges);
|
||||
expect(coach.tips.some((t) => t.id.startsWith("sec-"))).toBe(true);
|
||||
});
|
||||
});
|
||||
211
apps/web/src/lib/dofCoach.ts
Normal file
211
apps/web/src/lib/dofCoach.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Actionable DoF coaching — turns ledger diagnostics into ordered next steps
|
||||
* (guide, not just a red/green light).
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import {
|
||||
computeDofLedger,
|
||||
type DofBalance,
|
||||
type DofLedger,
|
||||
} from "./dofLedger";
|
||||
import { COMPONENT_BY_TYPE } from "./componentMeta";
|
||||
import { isBoundaryParamFixed } from "./boundaryFix";
|
||||
import { hxFamily } from "./hxFamily";
|
||||
|
||||
export type CoachSeverity = "ok" | "tip" | "warn" | "block";
|
||||
|
||||
export interface CoachTip {
|
||||
id: string;
|
||||
severity: CoachSeverity;
|
||||
/** Short headline */
|
||||
title: string;
|
||||
/** What to do next */
|
||||
action: string;
|
||||
/** Optional component name to highlight */
|
||||
focus?: string;
|
||||
}
|
||||
|
||||
export interface DofCoach {
|
||||
balance: DofBalance;
|
||||
headline: string;
|
||||
tips: CoachTip[];
|
||||
ledger: DofLedger;
|
||||
}
|
||||
|
||||
export function buildDofCoach(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
): DofCoach {
|
||||
const ledger = computeDofLedger(nodes, edges);
|
||||
const tips: CoachTip[] = [];
|
||||
|
||||
if (ledger.balance === "empty") {
|
||||
return {
|
||||
balance: "empty",
|
||||
headline: "Glisse des composants depuis la bibliothèque pour commencer.",
|
||||
tips: [
|
||||
{
|
||||
id: "empty",
|
||||
severity: "tip",
|
||||
title: "Feuille vide",
|
||||
action:
|
||||
"Exemple : Compresseur → Condenseur → Détendeur → Évaporateur, puis boucles eau/air Source→HX→Sink.",
|
||||
},
|
||||
],
|
||||
ledger,
|
||||
};
|
||||
}
|
||||
|
||||
// Topology coaching
|
||||
const types = new Set(nodes.map((n) => n.data.type));
|
||||
const hasComp = [...types].some((t) => t.includes("Compressor"));
|
||||
const hasExv = [...types].some((t) => t.includes("Valve") || t.includes("Expansion"));
|
||||
const hasCond = [...types].some((t) => t.includes("Condenser"));
|
||||
const hasEvap = [...types].some((t) => t.includes("Evaporator"));
|
||||
|
||||
if (hasComp && hasCond && hasEvap && hasExv) {
|
||||
const openHx = nodes.filter((n) => {
|
||||
const fam = hxFamily(n.data.type);
|
||||
if (!fam) return false;
|
||||
const meta = COMPONENT_BY_TYPE[n.data.type];
|
||||
if (!meta) return false;
|
||||
const secPorts = meta.ports.filter((p) => p.includes("secondary"));
|
||||
if (secPorts.length < 2) return false;
|
||||
const wired = edges.some(
|
||||
(e) =>
|
||||
(e.source === n.id || e.target === n.id) &&
|
||||
(String(e.sourceHandle).includes("secondary") ||
|
||||
String(e.targetHandle).includes("secondary")),
|
||||
);
|
||||
return !wired && !truthy(n.data.params.secondary_mass_flow_kg_s);
|
||||
});
|
||||
for (const n of openHx.slice(0, 2)) {
|
||||
const fam = hxFamily(n.data.type);
|
||||
tips.push({
|
||||
id: `sec-${n.id}`,
|
||||
severity: "tip",
|
||||
title: `${n.data.name} — secondaire non câblé`,
|
||||
action: fam
|
||||
? `Relie BrineSource/AirSource → secondary_in → secondary_out → Sink (${fam.label}).`
|
||||
: "Relie une boucle Source → secondary → Sink.",
|
||||
focus: n.data.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Boundary Fixed/Free coaching
|
||||
for (const n of nodes) {
|
||||
if (!n.data.type.endsWith("Source")) continue;
|
||||
const p = n.data.params;
|
||||
const meta = COMPONENT_BY_TYPE[n.data.type];
|
||||
if (!meta) continue;
|
||||
const pMeta = meta.params.find((x) => x.key === "p_set_bar");
|
||||
const mMeta = meta.params.find((x) => x.key === "m_flow_kg_s");
|
||||
if (!pMeta || !mMeta) continue;
|
||||
const fixP = isBoundaryParamFixed(p, pMeta);
|
||||
const fixM =
|
||||
isBoundaryParamFixed(p, mMeta) &&
|
||||
p.m_flow_kg_s !== undefined &&
|
||||
p.m_flow_kg_s !== "" &&
|
||||
Number(p.m_flow_kg_s) > 0;
|
||||
if (fixP && fixM) {
|
||||
tips.push({
|
||||
id: `mflow-p-${n.id}`,
|
||||
severity: "warn",
|
||||
title: `${n.data.name} — Fixed P + Fixed ṁ`,
|
||||
action:
|
||||
"Modelica MassFlowSource_T : décoche Fixed sur P (garde ṁ + T). Le Sink ancre la pression.",
|
||||
focus: n.data.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Air humidity tip
|
||||
for (const n of nodes) {
|
||||
if (!n.data.type.includes("Condenser") && !n.data.type.includes("Evaporator")) continue;
|
||||
if (String(n.data.params.secondary_fluid ?? "").toLowerCase() !== "air") continue;
|
||||
const w = n.data.params.secondary_humidity_ratio;
|
||||
if (w === undefined || w === "" || Number(w) <= 0) {
|
||||
tips.push({
|
||||
id: `w-${n.id}`,
|
||||
severity: "warn",
|
||||
title: `${n.data.name} — W air manquant`,
|
||||
action:
|
||||
"Renseigne secondary_humidity_ratio (= W de l’AirSource, ex. 0.014 à 35 °C / 40 % RH) sinon le solveur peut diverger.",
|
||||
focus: n.data.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ledger diagnostics → tips
|
||||
for (const [i, d] of ledger.diagnostics.slice(0, 8).entries()) {
|
||||
const sev: CoachSeverity =
|
||||
ledger.balance === "balanced" && !d.startsWith("Over") && !d.startsWith("Under")
|
||||
? "tip"
|
||||
: "warn";
|
||||
tips.push({
|
||||
id: `diag-${i}`,
|
||||
severity: sev,
|
||||
title: shortenDiagTitle(d),
|
||||
action: d,
|
||||
});
|
||||
}
|
||||
|
||||
if (ledger.balance === "over-constrained") {
|
||||
tips.unshift({
|
||||
id: "over",
|
||||
severity: "block",
|
||||
title: `Trop d’équations (+${ledger.delta})`,
|
||||
action:
|
||||
"Décoche Fixed sur une frontière, active emergent_pressure, ou libère un actionneur (Z_UA, ouverture EXV).",
|
||||
});
|
||||
} else if (ledger.balance === "under-constrained") {
|
||||
tips.unshift({
|
||||
id: "under",
|
||||
severity: "block",
|
||||
title: `Pas assez d’équations (${ledger.delta})`,
|
||||
action:
|
||||
"Fixe ṁ ou T sur une Source, ancre P sur le Sink, ou coche emergent_pressure + sous-refroidissement / surchauffe.",
|
||||
});
|
||||
} else if (tips.length === 0) {
|
||||
tips.push({
|
||||
id: "ok",
|
||||
severity: "ok",
|
||||
title: "Système carré",
|
||||
action: "Tu peux lancer Solve. Les warnings soft (OutletClosure émergent) sont normaux.",
|
||||
});
|
||||
}
|
||||
|
||||
// Deduplicate by title+action
|
||||
const seen = new Set<string>();
|
||||
const unique = tips.filter((t) => {
|
||||
const k = `${t.title}|${t.action}`;
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
|
||||
const headline =
|
||||
ledger.balance === "balanced"
|
||||
? "Balance OK — prêt à simuler"
|
||||
: ledger.balance === "over-constrained"
|
||||
? "Trop de Fixed — suis les étapes ci-dessous"
|
||||
: "Il manque des contraintes — suis le guide";
|
||||
|
||||
return { balance: ledger.balance, headline, tips: unique.slice(0, 10), ledger };
|
||||
}
|
||||
|
||||
function truthy(v: unknown): boolean {
|
||||
return v === true || v === "true" || v === 1 || v === "1";
|
||||
}
|
||||
|
||||
function shortenDiagTitle(d: string): string {
|
||||
if (d.includes("MassFlowSource") || d.includes("Fixed P + Fixed")) return "Conflit P / ṁ";
|
||||
if (d.includes("secondary")) return "Boucle secondaire";
|
||||
if (d.includes("quality_control")) return "quality_control";
|
||||
if (d.includes("Over-constrained")) return "Surcontraint";
|
||||
if (d.includes("Under-constrained")) return "Sous-contraint";
|
||||
return d.length > 48 ? `${d.slice(0, 46)}…` : d;
|
||||
}
|
||||
199
apps/web/src/lib/dofLedger.test.ts
Normal file
199
apps/web/src/lib/dofLedger.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
classifyParamDof,
|
||||
computeDofLedger,
|
||||
estimateComponentEquations,
|
||||
} from "./dofLedger";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
params: Record<string, number | string | boolean> = {},
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropyk",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, circuit: 0, params },
|
||||
};
|
||||
}
|
||||
|
||||
function edge(
|
||||
id: string,
|
||||
source: string,
|
||||
target: string,
|
||||
sourceHandle: string,
|
||||
targetHandle: string,
|
||||
): Edge {
|
||||
return { id, source, target, sourceHandle, targetHandle };
|
||||
}
|
||||
|
||||
describe("computeDofLedger", () => {
|
||||
it("returns empty for no nodes", () => {
|
||||
const L = computeDofLedger([], []);
|
||||
expect(L.balance).toBe("empty");
|
||||
expect(L.nEquations).toBe(0);
|
||||
expect(L.nUnknowns).toBe(0);
|
||||
});
|
||||
|
||||
it("counts a water-cooled 4-port chiller close to 19=19", () => {
|
||||
// Rough topology of chiller_watercooled_r410a.json
|
||||
const nodes = [
|
||||
node("c", "IsentropicCompressor", "comp", { emergent_pressure: true }),
|
||||
node("cd", "Condenser", "cond", { emergent_pressure: true }),
|
||||
node("x", "IsenthalpicExpansionValve", "exv", { emergent_pressure: true }),
|
||||
node("e", "Evaporator", "evap", { emergent_pressure: true }),
|
||||
node("cwi", "BrineSource", "cond_water_in", { m_flow_kg_s: 0.4 }),
|
||||
node("cwo", "BrineSink", "cond_water_out"),
|
||||
node("ewi", "BrineSource", "evap_water_in", { m_flow_kg_s: 0.5 }),
|
||||
node("ewo", "BrineSink", "evap_water_out"),
|
||||
];
|
||||
const edges = [
|
||||
edge("1", "c", "cd", "outlet", "inlet"),
|
||||
edge("2", "cd", "x", "outlet", "inlet"),
|
||||
edge("3", "x", "e", "outlet", "inlet"),
|
||||
edge("4", "e", "c", "outlet", "inlet"),
|
||||
edge("5", "cwi", "cd", "outlet", "secondary_inlet"),
|
||||
edge("6", "cd", "cwo", "secondary_outlet", "inlet"),
|
||||
edge("7", "ewi", "e", "outlet", "secondary_inlet"),
|
||||
edge("8", "e", "ewo", "secondary_outlet", "inlet"),
|
||||
];
|
||||
const L = computeDofLedger(nodes, edges);
|
||||
// Expect square-ish: honest budget is 19. Client estimate should match.
|
||||
expect(L.nEdges).toBe(8);
|
||||
expect(L.nBranches).toBe(3); // ref + cw + chw
|
||||
expect(L.nUnknowns).toBe(3 + 2 * 8); // 19
|
||||
expect(L.nEquations).toBe(19);
|
||||
expect(L.balance).toBe("balanced");
|
||||
});
|
||||
|
||||
it("flags quality_control without free actuator as over-constrained risk", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", { quality_control: true }),
|
||||
node("s", "BrineSource", "src", { m_flow_kg_s: 1 }),
|
||||
node("k", "BrineSink", "sink"),
|
||||
];
|
||||
const edges = [
|
||||
edge("a", "s", "e", "outlet", "secondary_inlet"),
|
||||
edge("b", "e", "k", "secondary_outlet", "inlet"),
|
||||
];
|
||||
const est = estimateComponentEquations(nodes[0], edges, nodes);
|
||||
expect(est.nEquations).toBe(5); // ΔP + energy + outlet_closure + P_sec + energy_sec
|
||||
expect(est.roles.some((r) => r.includes("quality"))).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores orphan pipes in the equation count (no hard-block)", () => {
|
||||
const nodes = [
|
||||
node("c", "IsentropicCompressor", "comp", { emergent_pressure: true }),
|
||||
node("cd", "Condenser", "cond", { emergent_pressure: true }),
|
||||
node("x", "IsenthalpicExpansionValve", "exv", { emergent_pressure: true }),
|
||||
node("e", "Evaporator", "evap", { emergent_pressure: true }),
|
||||
node("cwi", "BrineSource", "cond_water_in", { m_flow_kg_s: 0.4 }),
|
||||
node("cwo", "BrineSink", "cond_water_out"),
|
||||
node("ewi", "BrineSource", "evap_water_in", { m_flow_kg_s: 0.5 }),
|
||||
node("ewo", "BrineSink", "evap_water_out"),
|
||||
node("p", "RefrigerantPipe", "line1", { length_m: 3, diameter_m: 0.012 }),
|
||||
];
|
||||
const edges = [
|
||||
edge("1", "c", "cd", "outlet", "inlet"),
|
||||
edge("2", "cd", "x", "outlet", "inlet"),
|
||||
edge("3", "x", "e", "outlet", "inlet"),
|
||||
edge("4", "e", "c", "outlet", "inlet"),
|
||||
edge("5", "cwi", "cd", "outlet", "secondary_inlet"),
|
||||
edge("6", "cd", "cwo", "secondary_outlet", "inlet"),
|
||||
edge("7", "ewi", "e", "outlet", "secondary_inlet"),
|
||||
edge("8", "e", "ewo", "secondary_outlet", "inlet"),
|
||||
];
|
||||
const L = computeDofLedger(nodes, edges);
|
||||
expect(L.balance).toBe("balanced");
|
||||
expect(L.components.find((c) => c.name === "line1")?.nEquations).toBe(0);
|
||||
});
|
||||
|
||||
it("counts a spliced refrigerant pipe as +2 eqs / +1 edge (neutral DoF)", () => {
|
||||
const nodes = [
|
||||
node("c", "IsentropicCompressor", "comp", { emergent_pressure: true }),
|
||||
node("cd", "Condenser", "cond", { emergent_pressure: true }),
|
||||
node("x", "IsenthalpicExpansionValve", "exv", { emergent_pressure: true }),
|
||||
node("e", "Evaporator", "evap", { emergent_pressure: true }),
|
||||
node("cwi", "BrineSource", "cond_water_in", { m_flow_kg_s: 0.4 }),
|
||||
node("cwo", "BrineSink", "cond_water_out"),
|
||||
node("ewi", "BrineSource", "evap_water_in", { m_flow_kg_s: 0.5 }),
|
||||
node("ewo", "BrineSink", "evap_water_out"),
|
||||
node("p", "RefrigerantPipe", "line1", { length_m: 3, diameter_m: 0.012 }),
|
||||
];
|
||||
const edges = [
|
||||
edge("1a", "c", "p", "outlet", "inlet"),
|
||||
edge("1b", "p", "cd", "outlet", "inlet"),
|
||||
edge("2", "cd", "x", "outlet", "inlet"),
|
||||
edge("3", "x", "e", "outlet", "inlet"),
|
||||
edge("4", "e", "c", "outlet", "inlet"),
|
||||
edge("5", "cwi", "cd", "outlet", "secondary_inlet"),
|
||||
edge("6", "cd", "cwo", "secondary_outlet", "inlet"),
|
||||
edge("7", "ewi", "e", "outlet", "secondary_inlet"),
|
||||
edge("8", "e", "ewo", "secondary_outlet", "inlet"),
|
||||
];
|
||||
const L = computeDofLedger(nodes, edges);
|
||||
expect(L.nEdges).toBe(9);
|
||||
expect(L.nEquations).toBe(21);
|
||||
expect(L.nUnknowns).toBe(L.nBranches + 2 * 9);
|
||||
expect(L.balance).toBe("balanced");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyParamDof", () => {
|
||||
it("marks emergent t_sat as seed not fix", () => {
|
||||
const tag = classifyParamDof("Condenser", "t_sat_k", { emergent_pressure: true });
|
||||
expect(tag?.kind).toBe("solved");
|
||||
});
|
||||
|
||||
it("marks boundary T as FIX", () => {
|
||||
const tag = classifyParamDof("BrineSource", "t_set_c", { t_set_c: 12 });
|
||||
expect(tag?.kind).toBe("fixed");
|
||||
});
|
||||
|
||||
it("marks Free ṁ on BrineSource as FREE", () => {
|
||||
const tag = classifyParamDof("BrineSource", "m_flow_kg_s", {
|
||||
m_flow_kg_s: 0.5,
|
||||
__fixed_m_flow_kg_s: false,
|
||||
});
|
||||
expect(tag?.kind).toBe("free");
|
||||
});
|
||||
|
||||
it("flags Fixed T_out + Fixed ṁ as ΔT rating conflict", () => {
|
||||
const nodes = [
|
||||
node("e", "FloodedEvaporator", "evap", { ua: 8000 }),
|
||||
node("s", "BrineSource", "src", {
|
||||
p_set_bar: 3,
|
||||
t_set_c: 12,
|
||||
m_flow_kg_s: 0.55,
|
||||
__fixed_m_flow_kg_s: true,
|
||||
}),
|
||||
node("k", "BrineSink", "sink", {
|
||||
p_back_bar: 3,
|
||||
t_set_c: 7,
|
||||
__fixed_t_set_c: true,
|
||||
}),
|
||||
];
|
||||
const edges = [
|
||||
edge("1", "s", "e", "outlet", "secondary_inlet"),
|
||||
edge("2", "e", "k", "secondary_outlet", "inlet"),
|
||||
];
|
||||
const ledger = computeDofLedger(nodes, edges);
|
||||
expect(ledger.diagnostics.some((d) => d.includes("Fixed T_out") && d.includes("Fixed ṁ"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("marks scalar secondary as risk without live ports", () => {
|
||||
const tag = classifyParamDof(
|
||||
"FloodedEvaporator",
|
||||
"secondary_inlet_temp_c",
|
||||
{ secondary_inlet_temp_c: 12 },
|
||||
{ hasLiveSecondary: false },
|
||||
);
|
||||
expect(tag?.kind).toBe("risk");
|
||||
});
|
||||
});
|
||||
702
apps/web/src/lib/dofLedger.ts
Normal file
702
apps/web/src/lib/dofLedger.ts
Normal file
@@ -0,0 +1,702 @@
|
||||
/**
|
||||
* Client-side degrees-of-freedom ledger for the diagram UI.
|
||||
*
|
||||
* Mirrors the solver rule `n_equations == n_unknowns` with a topology-based
|
||||
* estimate. This is intentionally a **live design aid**, not a bit-exact copy
|
||||
* of `System::dof_report()` (which requires a finalized Rust graph + CoolProp).
|
||||
*
|
||||
* Counting model (CM1.4 style):
|
||||
* unknowns ≈ n_mass_branches + 2 × n_edges (+ free actuators / controls)
|
||||
* equations ≈ Σ component residual estimates (+ control tracking residuals)
|
||||
*
|
||||
* Fix / Free discipline surfaced to the UI:
|
||||
* - Boundary sources FIX P, h, (ṁ)
|
||||
* - Outlet closures (SH / SC / quality) FIX a thermo state — need a FREE actuator
|
||||
* - Emergent pressure FREES the design-point pressure pin
|
||||
* - Controls: measure FIX paired with actuator FREE
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import {
|
||||
findSecondaryLoopDofConflicts,
|
||||
isBoundaryParamFixed,
|
||||
} from "./boundaryFix";
|
||||
import { COMPONENT_BY_TYPE, isSecondaryPort } from "./componentMeta";
|
||||
import { CONTROL_NODE_TYPE, type EntropykNodeData } from "./configBuilder";
|
||||
|
||||
export type DofBalance = "balanced" | "over-constrained" | "under-constrained" | "empty";
|
||||
|
||||
export type FixFreeKind = "fixed" | "free" | "solved" | "parameter" | "risk";
|
||||
|
||||
export interface ParamDofTag {
|
||||
key: string;
|
||||
kind: FixFreeKind;
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface ComponentDofEstimate {
|
||||
name: string;
|
||||
type: string;
|
||||
nEquations: number;
|
||||
roles: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface DofLedger {
|
||||
nEquations: number;
|
||||
nUnknowns: number;
|
||||
balance: DofBalance;
|
||||
delta: number;
|
||||
nEdges: number;
|
||||
nBranches: number;
|
||||
nControls: number;
|
||||
components: ComponentDofEstimate[];
|
||||
diagnostics: string[];
|
||||
/** Human one-liner for the status bar. */
|
||||
summary: string;
|
||||
}
|
||||
|
||||
function truthy(v: unknown): boolean {
|
||||
return v === true || v === "true" || v === 1 || v === "1";
|
||||
}
|
||||
|
||||
function num(v: unknown, fallback = 0): number {
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Edges incident to a node (by React Flow id). */
|
||||
function incidentEdges(nodeId: string, edges: Edge[]): Edge[] {
|
||||
return edges.filter((e) => e.source === nodeId || e.target === nodeId);
|
||||
}
|
||||
|
||||
/** Whether a HX has both secondary ports wired. */
|
||||
function hasLiveSecondary(node: Node<EntropykNodeData>, edges: Edge[]): boolean {
|
||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||
if (!meta?.ports.some(isSecondaryPort)) return false;
|
||||
const connected = new Set<string>();
|
||||
for (const e of edges) {
|
||||
if (e.source === node.id && e.sourceHandle) connected.add(e.sourceHandle);
|
||||
if (e.target === node.id && e.targetHandle) connected.add(e.targetHandle);
|
||||
}
|
||||
return connected.has("secondary_inlet") && connected.has("secondary_outlet");
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate component residual count from type + params + wiring.
|
||||
* Conservative and documented; prefer over-counting diagnostics over silent under-count.
|
||||
*/
|
||||
export function estimateComponentEquations(
|
||||
node: Node<EntropykNodeData>,
|
||||
edges: Edge[],
|
||||
allNodes: Node<EntropykNodeData>[],
|
||||
): ComponentDofEstimate {
|
||||
const type = node.data.type;
|
||||
const name = node.data.name;
|
||||
const p = node.data.params ?? {};
|
||||
const roles: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let n = 0;
|
||||
|
||||
const liveSec = hasLiveSecondary(node, edges);
|
||||
const emergent = truthy(p.emergent_pressure);
|
||||
const skipP = truthy(p.skip_pressure_eq);
|
||||
|
||||
switch (type) {
|
||||
case "IsentropicCompressor":
|
||||
case "Compressor":
|
||||
// series branch: ṁ law + h_dis (mass residual dropped when same-branch)
|
||||
n = 2;
|
||||
roles.push("mass_or_volume_flow", "energy[discharge]");
|
||||
break;
|
||||
case "ScrewEconomizerCompressor":
|
||||
n = 3;
|
||||
roles.push("mass", "energy[discharge]", "economizer");
|
||||
break;
|
||||
case "CentrifugalCompressor":
|
||||
n = 2;
|
||||
roles.push("mass", "energy[discharge]");
|
||||
break;
|
||||
case "CapillaryTube":
|
||||
n = 2;
|
||||
roles.push("mass", "energy[isenthalpic]");
|
||||
break;
|
||||
case "IsenthalpicExpansionValve":
|
||||
case "EXV":
|
||||
case "ExpansionValve":
|
||||
// emergent: isenthalpic only (1); fixed-P: +P_evap pin (2); orifice: +flow residual
|
||||
n = emergent ? 1 : 2;
|
||||
roles.push("energy[isenthalpic]");
|
||||
if (!emergent) roles.push("boundary[P_evap]");
|
||||
if (p.orifice_kv !== undefined && p.orifice_kv !== "" && p.orifice_kv !== null) {
|
||||
n += 1;
|
||||
roles.push("actuator[orifice]");
|
||||
}
|
||||
break;
|
||||
case "Condenser":
|
||||
case "CondenserCoil":
|
||||
case "MchxCondenserCoil":
|
||||
case "FinCoilCondenser":
|
||||
case "FloodedCondenser":
|
||||
case "AirCooledCondenser": {
|
||||
n = skipP ? 1 : 2; // ΔP + energy
|
||||
roles.push(skipP ? "energy[refrigerant]" : "momentum[refrigerant]", "energy[refrigerant]");
|
||||
if (emergent) {
|
||||
n += 1;
|
||||
roles.push("outlet_closure[subcooling]");
|
||||
}
|
||||
if (liveSec) {
|
||||
// Isobaric secondary P + energy (same-branch mass dropped) — Modelica
|
||||
// MassFlowSource_T Free P needs HX to propagate sink P to source edge.
|
||||
n += 2;
|
||||
roles.push("momentum[secondary]", "energy[secondary]");
|
||||
} else {
|
||||
warnings.push(
|
||||
"No live secondary ports — system-mode coupling needs BrineSource→secondary_in→secondary_out→BrineSink",
|
||||
);
|
||||
}
|
||||
if (p.fan_head_pressure_target_c !== undefined && p.fan_head_pressure_target_c !== "") {
|
||||
n += 1;
|
||||
roles.push("actuator[fan_head_pressure]");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Evaporator":
|
||||
case "EvaporatorCoil": {
|
||||
n = skipP ? 1 : 2;
|
||||
roles.push(skipP ? "energy[refrigerant]" : "momentum[refrigerant]", "energy[refrigerant]");
|
||||
const superheatReg = truthy(p.superheat_regulated);
|
||||
if (emergent && !superheatReg) {
|
||||
n += 1;
|
||||
roles.push("outlet_closure[superheat]");
|
||||
}
|
||||
if (superheatReg) {
|
||||
roles.push("superheat_regulated(drop SH residual)");
|
||||
const hasCtrl = allNodes.some(
|
||||
(n) =>
|
||||
n.data.type === CONTROL_NODE_TYPE &&
|
||||
String(n.data.params?.measure_component ?? "") === name,
|
||||
);
|
||||
if (!hasCtrl) {
|
||||
warnings.push(
|
||||
"superheat_regulated drops the SH residual — pair with a SaturatedController (EXV opening)",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (liveSec) {
|
||||
n += 2;
|
||||
roles.push("momentum[secondary]", "energy[secondary]");
|
||||
} else {
|
||||
warnings.push(
|
||||
"No live secondary ports — connect a water/brine loop for real-machine energy balance",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "FloodedEvaporator": {
|
||||
// ΔP + energy + outlet closure (saturated vapor default, or quality if enabled)
|
||||
n = 3;
|
||||
roles.push("momentum[refrigerant]", "energy[refrigerant]");
|
||||
if (truthy(p.quality_control)) {
|
||||
roles.push("outlet_closure[quality]");
|
||||
warnings.push(
|
||||
"quality_control uses q_target instead of saturated-vapor suction closure — pair with free actuator if this over-constrains controls",
|
||||
);
|
||||
} else {
|
||||
roles.push("outlet_closure[saturated_vapor]");
|
||||
}
|
||||
if (liveSec) {
|
||||
n += 2;
|
||||
roles.push("momentum[secondary]", "energy[secondary]");
|
||||
} else {
|
||||
warnings.push(
|
||||
"Flooded system mode requires live secondary edges; scalar secondary_* fields are rating-only",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "BrineSource":
|
||||
case "AirSource":
|
||||
case "RefrigerantSource": {
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
const pMeta = meta?.params.find((x) => x.key === "p_set_bar");
|
||||
const tMeta = meta?.params.find((x) => x.key === "t_set_c" || x.key === "t_dry_c");
|
||||
const mMeta = meta?.params.find((x) => x.key === "m_flow_kg_s");
|
||||
const fixP = !pMeta?.fixable || isBoundaryParamFixed(p, pMeta);
|
||||
const fixT = !tMeta?.fixable || isBoundaryParamFixed(p, tMeta);
|
||||
const fixM = !mMeta?.fixable || isBoundaryParamFixed(p, mMeta);
|
||||
n = 0;
|
||||
if (fixP) {
|
||||
n += 1;
|
||||
roles.push("boundary[P]");
|
||||
}
|
||||
if (fixT) {
|
||||
n += 1;
|
||||
roles.push("boundary[h]");
|
||||
}
|
||||
if (
|
||||
fixM &&
|
||||
p.m_flow_kg_s !== undefined &&
|
||||
p.m_flow_kg_s !== "" &&
|
||||
num(p.m_flow_kg_s) > 0
|
||||
) {
|
||||
n += 1;
|
||||
roles.push("boundary[m]");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "BrineSink":
|
||||
case "AirSink":
|
||||
case "RefrigerantSink": {
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
const pMeta = meta?.params.find((x) => x.key === "p_back_bar");
|
||||
const tMeta = meta?.params.find((x) => x.key === "t_set_c" || x.key === "t_back_c");
|
||||
const fixP = !pMeta?.fixable || isBoundaryParamFixed(p, pMeta);
|
||||
n = 0;
|
||||
if (fixP) {
|
||||
n += 1;
|
||||
roles.push("boundary[P]");
|
||||
}
|
||||
const tKey = type === "AirSink" ? "t_back_c" : "t_set_c";
|
||||
const tFixed = tMeta
|
||||
? isBoundaryParamFixed(p, tMeta)
|
||||
: p[tKey] !== undefined && p[tKey] !== "";
|
||||
if (tFixed && p[tKey] !== undefined && p[tKey] !== "") {
|
||||
n += 1;
|
||||
roles.push("boundary[h]");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Anchor":
|
||||
n = 2;
|
||||
roles.push("continuity[P]", "continuity[h]");
|
||||
if (p.constraint || p.superheat_k || p.quality || p.t_set_k || p.p_set_pa) {
|
||||
n += 1;
|
||||
roles.push("outlet_closure[spec]");
|
||||
warnings.push("Anchor constraint FIX consumes one DoF — free something elsewhere");
|
||||
}
|
||||
break;
|
||||
case "HeatExchanger":
|
||||
case "BphxEvaporator":
|
||||
case "BphxCondenser":
|
||||
case "Economizer":
|
||||
case "FreeCoolingExchanger":
|
||||
n = 2;
|
||||
roles.push("energy[side_a]", "energy[side_b]");
|
||||
if (liveSec) {
|
||||
n += 0; // already in 4-port energy
|
||||
}
|
||||
break;
|
||||
case "Pump":
|
||||
case "Fan":
|
||||
n = 2;
|
||||
roles.push("momentum", "energy");
|
||||
break;
|
||||
case "Pipe":
|
||||
case "RefrigerantPipe":
|
||||
case "WaterPipe":
|
||||
case "AirDuct":
|
||||
case "PipeWater":
|
||||
case "PipeAir":
|
||||
case "ReversingValve":
|
||||
case "BypassValve": {
|
||||
// Unwired 2-port parts must not inflate the ledger — an orphan pipe
|
||||
// (+2 eqs, +0 edges) hard-blocks Simulate as "over-constrained".
|
||||
const degree = incidentEdges(node.id, edges).length;
|
||||
if (degree < 2) {
|
||||
n = 0;
|
||||
roles.push("unconnected");
|
||||
warnings.push(
|
||||
degree === 0
|
||||
? "not connected — drop on a matching wire or connect inlet+outlet"
|
||||
: "partially connected (need inlet + outlet)",
|
||||
);
|
||||
} else {
|
||||
n = 2;
|
||||
roles.push("momentum", "energy");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Drum":
|
||||
n = 4;
|
||||
roles.push("mass", "energy", "level_or_volume", "outlet");
|
||||
break;
|
||||
case "ThermalLoad":
|
||||
case "HeatSource":
|
||||
n = 2;
|
||||
roles.push("mass", "energy");
|
||||
break;
|
||||
case "FlowSplitter":
|
||||
case "FlowMerger":
|
||||
n = 2;
|
||||
roles.push("mass", "pressure");
|
||||
break;
|
||||
case "Placeholder":
|
||||
n = Math.max(0, Math.floor(num(p.n_equations, 2)));
|
||||
roles.push(`placeholder×${n}`);
|
||||
break;
|
||||
case CONTROL_NODE_TYPE:
|
||||
// Controller residuals are counted at system level (2 per loop).
|
||||
n = 0;
|
||||
roles.push("control(system-level)");
|
||||
break;
|
||||
default:
|
||||
n = 2;
|
||||
roles.push("unspecified×2");
|
||||
warnings.push(`No DoF template for type '${type}' — assumed 2 equations`);
|
||||
}
|
||||
|
||||
// Series refrigerant/secondary: same-branch drops mass residual — already assumed
|
||||
// for 2-port chain components above.
|
||||
|
||||
return { name, type, nEquations: n, roles, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate mass-flow branches: connected components of edges that share a series
|
||||
* path (1-in/1-out through non-junction nodes). Approximate with undirected edge
|
||||
* connectivity partitioned by fluid "kind" (secondary vs refrigerant) via handles.
|
||||
*/
|
||||
function estimateBranches(nodes: Node<EntropykNodeData>[], edges: Edge[]): number {
|
||||
if (edges.length === 0) return 0;
|
||||
|
||||
// Union-Find over edges: two edges share a branch if they meet at a 1-in/1-out
|
||||
// component on matching stream (both secondary or both primary).
|
||||
const parent = new Map<string, string>();
|
||||
const find = (x: string): string => {
|
||||
let p = parent.get(x) ?? x;
|
||||
while (parent.get(p) && parent.get(p) !== p) p = parent.get(p)!;
|
||||
parent.set(x, p);
|
||||
return p;
|
||||
};
|
||||
const unite = (a: string, b: string) => {
|
||||
const ra = find(a);
|
||||
const rb = find(b);
|
||||
if (ra !== rb) parent.set(ra, rb);
|
||||
};
|
||||
for (const e of edges) parent.set(e.id, e.id);
|
||||
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
for (const n of nodes) {
|
||||
if (n.data.type === CONTROL_NODE_TYPE) continue;
|
||||
const inc = incidentEdges(n.id, edges);
|
||||
// Group incident edges by stream class
|
||||
const ref: Edge[] = [];
|
||||
const sec: Edge[] = [];
|
||||
for (const e of inc) {
|
||||
const handle = e.source === n.id ? e.sourceHandle : e.targetHandle;
|
||||
if (handle && isSecondaryPort(handle)) sec.push(e);
|
||||
else ref.push(e);
|
||||
}
|
||||
// Series: exactly 2 edges on a stream → same branch
|
||||
if (ref.length === 2) unite(ref[0].id, ref[1].id);
|
||||
if (sec.length === 2) unite(sec[0].id, sec[1].id);
|
||||
|
||||
// Explicit flow_paths style for 4-port HX already covered by sec/ref grouping.
|
||||
void nodeById;
|
||||
}
|
||||
|
||||
const roots = new Set<string>();
|
||||
for (const e of edges) roots.add(find(e.id));
|
||||
return roots.size;
|
||||
}
|
||||
|
||||
/** Count free actuators implied by the diagram (orifice, fan head-pressure, controls). */
|
||||
function estimateExtraUnknowns(nodes: Node<EntropykNodeData>[]): {
|
||||
freeActuators: number;
|
||||
saturatedPairs: number;
|
||||
controlDiagnostics: string[];
|
||||
} {
|
||||
let freeActuators = 0;
|
||||
let saturatedPairs = 0;
|
||||
const controlDiagnostics: string[] = [];
|
||||
|
||||
for (const n of nodes) {
|
||||
const p = n.data.params ?? {};
|
||||
if (
|
||||
(n.data.type === "IsenthalpicExpansionValve" || n.data.type === "EXV") &&
|
||||
p.orifice_kv !== undefined &&
|
||||
p.orifice_kv !== "" &&
|
||||
p.orifice_kv !== null
|
||||
) {
|
||||
freeActuators += 1; // opening unknown
|
||||
}
|
||||
if (p.fan_head_pressure_target_c !== undefined && p.fan_head_pressure_target_c !== "") {
|
||||
freeActuators += 1;
|
||||
}
|
||||
if (n.data.type === CONTROL_NODE_TYPE) {
|
||||
saturatedPairs += 1; // (u, x) + 2 residuals → neutral if paired
|
||||
}
|
||||
}
|
||||
|
||||
// Orphan quality_control without free actuator
|
||||
for (const n of nodes) {
|
||||
if (n.data.type === "FloodedEvaporator" && truthy(n.data.params?.quality_control)) {
|
||||
if (freeActuators + saturatedPairs === 0) {
|
||||
controlDiagnostics.push(
|
||||
`${n.data.name}: quality_control FIX without free actuator → over-constrained risk`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { freeActuators, saturatedPairs, controlDiagnostics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full client-side DoF ledger for the current diagram.
|
||||
*/
|
||||
export function computeDofLedger(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
): DofLedger {
|
||||
if (nodes.length === 0) {
|
||||
return {
|
||||
nEquations: 0,
|
||||
nUnknowns: 0,
|
||||
balance: "empty",
|
||||
delta: 0,
|
||||
nEdges: 0,
|
||||
nBranches: 0,
|
||||
nControls: 0,
|
||||
components: [],
|
||||
diagnostics: [],
|
||||
summary: "No components — empty system",
|
||||
};
|
||||
}
|
||||
|
||||
const physNodes = nodes.filter((n) => n.data.type !== CONTROL_NODE_TYPE);
|
||||
const components = physNodes.map((n) => estimateComponentEquations(n, edges, nodes));
|
||||
let nEquations = components.reduce((s, c) => s + c.nEquations, 0);
|
||||
|
||||
const nBranches = estimateBranches(nodes, edges);
|
||||
const nEdges = edges.length;
|
||||
// unknowns: ṁ per branch + (P,h) per edge
|
||||
let nUnknowns = nBranches + 2 * nEdges;
|
||||
|
||||
const { freeActuators, saturatedPairs, controlDiagnostics } = estimateExtraUnknowns(nodes);
|
||||
nUnknowns += freeActuators + 2 * saturatedPairs;
|
||||
nEquations += 2 * saturatedPairs; // saturated PI: +2 residuals per loop
|
||||
|
||||
const nControls = nodes.filter((n) => n.data.type === CONTROL_NODE_TYPE).length;
|
||||
|
||||
const diagnostics: string[] = [...controlDiagnostics];
|
||||
for (const c of components) {
|
||||
for (const w of c.warnings) diagnostics.push(`${c.name}: ${w}`);
|
||||
}
|
||||
|
||||
const delta = nEquations - nUnknowns;
|
||||
let balance: DofBalance;
|
||||
if (nEquations === nUnknowns) balance = "balanced";
|
||||
else if (nEquations > nUnknowns) balance = "over-constrained";
|
||||
else balance = "under-constrained";
|
||||
|
||||
for (const c of findSecondaryLoopDofConflicts(physNodes, edges)) {
|
||||
diagnostics.push(c.message);
|
||||
}
|
||||
|
||||
if (balance === "over-constrained") {
|
||||
diagnostics.unshift(
|
||||
`Over-constrained by ${delta}: remove a FIX (outlet closure / quality / boundary) or FREE an actuator`,
|
||||
);
|
||||
} else if (balance === "under-constrained") {
|
||||
diagnostics.unshift(
|
||||
`Under-constrained by ${-delta}: add a residual (emergent closure, boundary) or remove a free unknown`,
|
||||
);
|
||||
}
|
||||
|
||||
const summary =
|
||||
balance === "balanced"
|
||||
? `DoF balanced · ${nEquations} eqs = ${nUnknowns} unk`
|
||||
: balance === "over-constrained"
|
||||
? `DoF OVER · ${nEquations} eqs > ${nUnknowns} unk (+${delta})`
|
||||
: `DoF UNDER · ${nEquations} eqs < ${nUnknowns} unk (${delta})`;
|
||||
|
||||
return {
|
||||
nEquations,
|
||||
nUnknowns,
|
||||
balance,
|
||||
delta,
|
||||
nEdges,
|
||||
nBranches,
|
||||
nControls,
|
||||
components,
|
||||
diagnostics,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a parameter as fixed / free / solved / plain for the properties panel.
|
||||
*/
|
||||
export function classifyParamDof(
|
||||
type: string,
|
||||
key: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
opts?: { hasLiveSecondary?: boolean; hasControl?: boolean },
|
||||
): ParamDofTag | null {
|
||||
const emergent = truthy(params.emergent_pressure);
|
||||
const hasLive = opts?.hasLiveSecondary ?? false;
|
||||
|
||||
// Shared patterns
|
||||
if (key === "ua" || key === "z_ua" || key === "z_dp") {
|
||||
return {
|
||||
key,
|
||||
kind: "parameter",
|
||||
label: "param",
|
||||
hint: "Model parameter (not a Newton unknown unless inverse-calibrated)",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
key === "t_sat_k" ||
|
||||
key === "t_cond_k" ||
|
||||
key === "t_evap_k"
|
||||
) {
|
||||
if (emergent) {
|
||||
return {
|
||||
key,
|
||||
kind: "solved",
|
||||
label: "seed",
|
||||
hint: "Initialization / design guess only — pressure is SOLVED (emergent)",
|
||||
};
|
||||
}
|
||||
return {
|
||||
key,
|
||||
kind: "fixed",
|
||||
label: "FIX",
|
||||
hint: "Imposes a design-point pressure (fixed-P mode). Prefer emergent_pressure for real machines.",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "emergent_pressure") {
|
||||
return {
|
||||
key,
|
||||
kind: "free",
|
||||
label: "FREE P",
|
||||
hint: "When ON, condensing/evaporating pressure is free (solved from secondary balance)",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "superheat_k" || key === "subcooling_k") {
|
||||
if (key === "superheat_k" && truthy(params.superheat_regulated)) {
|
||||
return {
|
||||
key,
|
||||
kind: "solved",
|
||||
label: "target",
|
||||
hint: "Superheat residual dropped — closed by EXV controller (FREE opening)",
|
||||
};
|
||||
}
|
||||
if (emergent) {
|
||||
return {
|
||||
key,
|
||||
kind: "fixed",
|
||||
label: "FIX",
|
||||
hint: "Outlet closure residual (+1 eq). Consumes one DoF; OK when pressure is free.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
key,
|
||||
kind: "parameter",
|
||||
label: "param",
|
||||
hint: "Used with fixed-pressure mode",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "superheat_regulated") {
|
||||
return {
|
||||
key,
|
||||
kind: opts?.hasControl ? "free" : "risk",
|
||||
label: opts?.hasControl ? "pair OK" : "needs FREE",
|
||||
hint: "Drops SH residual (−1 eq). Must pair with SaturatedController → EXV opening.",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "quality_control") {
|
||||
return {
|
||||
key,
|
||||
kind: truthy(params.quality_control) ? "risk" : "parameter",
|
||||
label: truthy(params.quality_control) ? "FIX +1" : "off",
|
||||
hint: "When ON, adds quality residual. Pair with free actuator or leave OFF for suction models.",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "target_quality") {
|
||||
return {
|
||||
key,
|
||||
kind: truthy(params.quality_control) ? "fixed" : "parameter",
|
||||
label: truthy(params.quality_control) ? "FIX set" : "unused",
|
||||
hint: "Only active if quality_control is ON (costs +1 DoF)",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
key.startsWith("secondary_inlet_temp") ||
|
||||
key.startsWith("secondary_mass_flow") ||
|
||||
key.startsWith("secondary_capacity") ||
|
||||
key === "secondary_cp_j_per_kgk"
|
||||
) {
|
||||
if (hasLive) {
|
||||
return {
|
||||
key,
|
||||
kind: "parameter",
|
||||
label: "rating",
|
||||
hint: "Live secondary edges present — scalars are rating-only (ignored in system residual path)",
|
||||
};
|
||||
}
|
||||
return {
|
||||
key,
|
||||
kind: "risk",
|
||||
label: "scalar",
|
||||
hint: "Scalar secondary is NOT a water-loop unknown. Wire BrineSource/Sink to secondary ports for real machines.",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
type.endsWith("Source") &&
|
||||
(key === "t_set_c" || key === "p_set_bar" || key === "m_flow_kg_s" || key === "t_dry_c")
|
||||
) {
|
||||
const meta = COMPONENT_BY_TYPE[type]?.params.find((x) => x.key === key);
|
||||
const fixed = !meta?.fixable || isBoundaryParamFixed(params, meta);
|
||||
return {
|
||||
key,
|
||||
kind: fixed ? "fixed" : "free",
|
||||
label: fixed ? "FIX" : "FREE",
|
||||
hint: fixed
|
||||
? "Boundary Dirichlet — machine input (correct place to fix T/ṁ/P)"
|
||||
: "Free at source — Modelica Boundary_pT; pair with Fixed T_out on Sink",
|
||||
};
|
||||
}
|
||||
|
||||
if (type.endsWith("Sink") && (key === "p_back_bar" || key === "t_set_c" || key === "t_back_c")) {
|
||||
const meta = COMPONENT_BY_TYPE[type]?.params.find((x) => x.key === key);
|
||||
const fixed = meta ? isBoundaryParamFixed(params, meta) : params[key] !== undefined;
|
||||
return {
|
||||
key,
|
||||
kind: fixed ? "fixed" : "free",
|
||||
label: fixed ? "FIX" : "FREE",
|
||||
hint:
|
||||
key === "p_back_bar"
|
||||
? fixed
|
||||
? "Back-pressure fixed"
|
||||
: "Free back-pressure — unusual; ensure another P anchor exists"
|
||||
: fixed
|
||||
? "Fixed T_out — consumes DoF (pair with Free ṁ on Source)"
|
||||
: "T_out free (solved from energy)",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "opening" || key === "fan_speed" || key === "speed_hz") {
|
||||
return {
|
||||
key,
|
||||
kind: "free",
|
||||
label: "FREE?",
|
||||
hint: "Actuator candidate — free when co-solved by a control loop / free actuator residual",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
118
apps/web/src/lib/edgeInsert.test.ts
Normal file
118
apps/web/src/lib/edgeInsert.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import {
|
||||
findNearestEdge,
|
||||
planEdgeInsert,
|
||||
pipeMediaKind,
|
||||
validatePipeOnEdge,
|
||||
} from "./edgeInsert";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
x: number,
|
||||
y: number,
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: { x, y },
|
||||
width: 100,
|
||||
height: 60,
|
||||
data: { type, name, circuit: 0, rotation: 0, params: {} },
|
||||
};
|
||||
}
|
||||
|
||||
describe("edgeInsert", () => {
|
||||
it("classifies pipe media", () => {
|
||||
expect(pipeMediaKind("WaterPipe")).toBe("water");
|
||||
expect(pipeMediaKind("AirDuct")).toBe("air");
|
||||
expect(pipeMediaKind("RefrigerantPipe")).toBe("refrigerant");
|
||||
});
|
||||
|
||||
it("finds nearest edge and plans A→pipe→B splice", () => {
|
||||
const nodes = [
|
||||
node("a", "BrineSource", "evap_water_in", 0, 0),
|
||||
node("b", "FloodedEvaporator", "evap", 200, 0),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: "e1",
|
||||
source: "a",
|
||||
target: "b",
|
||||
sourceHandle: "outlet",
|
||||
targetHandle: "secondary_inlet",
|
||||
},
|
||||
];
|
||||
const hit = findNearestEdge({ x: 100, y: 30 }, nodes, edges, {
|
||||
preferMedia: "water",
|
||||
});
|
||||
expect(hit?.edge.id).toBe("e1");
|
||||
expect(hit?.media).toBe("water");
|
||||
|
||||
const ok = validatePipeOnEdge("WaterPipe", { fluid: "Water" }, hit!.edge, nodes);
|
||||
expect(ok.ok).toBe(true);
|
||||
|
||||
const plan = planEdgeInsert({
|
||||
type: "WaterPipe",
|
||||
id: "pipe1",
|
||||
name: "water_pipe_1",
|
||||
position: hit!.midpoint,
|
||||
params: { fluid: "Water", length_m: 5 },
|
||||
edge: hit!.edge,
|
||||
sourceCenter: hit!.sourceCenter,
|
||||
targetCenter: hit!.targetCenter,
|
||||
});
|
||||
expect(plan.edgeIdToRemove).toBe("e1");
|
||||
expect(plan.node.data.rotation).toBe(0); // left → right
|
||||
expect(plan.edgesToAdd).toHaveLength(2);
|
||||
expect(plan.edgesToAdd[0]).toMatchObject({
|
||||
source: "a",
|
||||
target: "pipe1",
|
||||
targetHandle: "inlet",
|
||||
});
|
||||
expect(plan.edgesToAdd[1]).toMatchObject({
|
||||
source: "pipe1",
|
||||
sourceHandle: "outlet",
|
||||
target: "b",
|
||||
});
|
||||
});
|
||||
|
||||
it("orients pipe 180° when flow is right-to-left", () => {
|
||||
const plan = planEdgeInsert({
|
||||
type: "RefrigerantPipe",
|
||||
id: "p2",
|
||||
name: "ref_1",
|
||||
position: { x: 0, y: 0 },
|
||||
params: {},
|
||||
edge: {
|
||||
id: "e",
|
||||
source: "right",
|
||||
target: "left",
|
||||
sourceHandle: "outlet",
|
||||
targetHandle: "inlet",
|
||||
},
|
||||
sourceCenter: { x: 300, y: 100 },
|
||||
targetCenter: { x: 100, y: 100 },
|
||||
});
|
||||
expect(plan.node.data.rotation).toBe(180);
|
||||
});
|
||||
|
||||
it("rejects water pipe on refrigerant edge", () => {
|
||||
const nodes = [
|
||||
node("c", "IsentropicCompressor", "comp", 0, 0),
|
||||
node("d", "Condenser", "cond", 200, 0),
|
||||
];
|
||||
const edge: Edge = {
|
||||
id: "e2",
|
||||
source: "c",
|
||||
target: "d",
|
||||
sourceHandle: "outlet",
|
||||
targetHandle: "inlet",
|
||||
};
|
||||
const check = validatePipeOnEdge("WaterPipe", { fluid: "Water" }, edge, nodes);
|
||||
expect(check.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
238
apps/web/src/lib/edgeInsert.ts
Normal file
238
apps/web/src/lib/edgeInsert.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Drop a 2-port insertable (pipe / duct) onto an existing wire:
|
||||
* A ──→ B becomes A ──→ pipe ──→ B
|
||||
*
|
||||
* The pipe is oriented along the edge (Modelica-style) so inlet faces the
|
||||
* upstream component — avoids React Flow arrow loops from wrong handle sides.
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import { nodeSize } from "./componentMeta";
|
||||
import { mediaForEdge, mediaForPort, type MediaKind } from "./mediaStyle";
|
||||
import {
|
||||
centerNodePosition,
|
||||
orientationAlongEdge,
|
||||
type RotationDeg,
|
||||
} from "./orientation";
|
||||
|
||||
export const PIPE_TYPES = new Set([
|
||||
"Pipe",
|
||||
"RefrigerantPipe",
|
||||
"WaterPipe",
|
||||
"AirDuct",
|
||||
"PipeWater",
|
||||
"PipeAir",
|
||||
]);
|
||||
|
||||
export function isPipeType(type: string): boolean {
|
||||
return PIPE_TYPES.has(type) || type.includes("Pipe") || type === "AirDuct";
|
||||
}
|
||||
|
||||
export function pipeMediaKind(type: string, params?: Record<string, unknown>): MediaKind {
|
||||
if (type === "WaterPipe" || type === "PipeWater") return "water";
|
||||
if (type === "AirDuct" || type === "PipeAir") return "air";
|
||||
if (type === "RefrigerantPipe") return "refrigerant";
|
||||
const fluid = params?.fluid;
|
||||
if (typeof fluid === "string") {
|
||||
const f = fluid.toLowerCase();
|
||||
if (f === "air" || f.startsWith("air")) return "air";
|
||||
if (f === "water" || f.includes("glycol") || f === "meg" || f === "brine") return "water";
|
||||
}
|
||||
return "refrigerant";
|
||||
}
|
||||
|
||||
function distToSegment(
|
||||
p: { x: number; y: number },
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
): number {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 < 1e-9) return Math.hypot(p.x - a.x, p.y - a.y);
|
||||
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const proj = { x: a.x + t * dx, y: a.y + t * dy };
|
||||
return Math.hypot(p.x - proj.x, p.y - proj.y);
|
||||
}
|
||||
|
||||
function nodeCenter(node: Node<EntropykNodeData>): { x: number; y: number } {
|
||||
const size = nodeSize(node.data.type);
|
||||
const w = (node.measured?.width ?? node.width ?? size.w) as number;
|
||||
const h = (node.measured?.height ?? node.height ?? size.h) as number;
|
||||
return { x: node.position.x + w / 2, y: node.position.y + h / 2 };
|
||||
}
|
||||
|
||||
export interface NearestEdgeHit {
|
||||
edge: Edge;
|
||||
distance: number;
|
||||
media: MediaKind;
|
||||
midpoint: { x: number; y: number };
|
||||
sourceCenter: { x: number; y: number };
|
||||
targetCenter: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** Find the closest edge to a flow-space point (optionally prefer matching media). */
|
||||
export function findNearestEdge(
|
||||
point: { x: number; y: number },
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
edges: Edge[],
|
||||
options?: { maxDistance?: number; preferMedia?: MediaKind },
|
||||
): NearestEdgeHit | null {
|
||||
const maxDistance = options?.maxDistance ?? 56;
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
let best: NearestEdgeHit | null = null;
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (const edge of edges) {
|
||||
const s = byId.get(edge.source);
|
||||
const t = byId.get(edge.target);
|
||||
if (!s || !t) continue;
|
||||
const a = nodeCenter(s);
|
||||
const b = nodeCenter(t);
|
||||
const distance = distToSegment(point, a, b);
|
||||
if (distance > maxDistance) continue;
|
||||
const media = mediaForEdge(s, edge.sourceHandle, t, edge.targetHandle);
|
||||
const mediaPenalty =
|
||||
options?.preferMedia && media !== options.preferMedia && media !== "unknown"
|
||||
? 40
|
||||
: 0;
|
||||
const score = distance + mediaPenalty;
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = {
|
||||
edge,
|
||||
distance,
|
||||
media,
|
||||
midpoint: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
||||
sourceCenter: a,
|
||||
targetCenter: b,
|
||||
};
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface InsertPlan {
|
||||
node: Node<EntropykNodeData>;
|
||||
edgesToAdd: Edge[];
|
||||
edgeIdToRemove: string;
|
||||
}
|
||||
|
||||
/** Build nodes/edges mutation to splice a 2-port component into an edge. */
|
||||
export function planEdgeInsert(args: {
|
||||
type: string;
|
||||
id: string;
|
||||
name: string;
|
||||
position: { x: number; y: number };
|
||||
params: Record<string, number | string | boolean>;
|
||||
edge: Edge;
|
||||
circuit?: number;
|
||||
sourceCenter: { x: number; y: number };
|
||||
targetCenter: { x: number; y: number };
|
||||
}): InsertPlan {
|
||||
const { type, id, name, params, edge } = args;
|
||||
const rotation: RotationDeg = orientationAlongEdge(args.sourceCenter, args.targetCenter);
|
||||
const size = nodeSize(type);
|
||||
// Center pipe on the edge midpoint (ignore caller position if it's off-center).
|
||||
const mid = {
|
||||
x: (args.sourceCenter.x + args.targetCenter.x) / 2,
|
||||
y: (args.sourceCenter.y + args.targetCenter.y) / 2,
|
||||
};
|
||||
const position = centerNodePosition(mid, size);
|
||||
|
||||
const node: Node<EntropykNodeData> = {
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position,
|
||||
data: {
|
||||
type,
|
||||
name,
|
||||
circuit: args.circuit ?? 0,
|
||||
rotation,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params,
|
||||
},
|
||||
};
|
||||
|
||||
// Orthogonal routing — smoother arrows than default when handles face correctly.
|
||||
const edgeType = "smoothstep";
|
||||
|
||||
const edgesToAdd: Edge[] = [
|
||||
{
|
||||
id: `${edge.id}-a-${id.slice(0, 8)}`,
|
||||
source: edge.source,
|
||||
sourceHandle: edge.sourceHandle ?? undefined,
|
||||
target: id,
|
||||
targetHandle: "inlet",
|
||||
animated: false,
|
||||
type: edgeType,
|
||||
},
|
||||
{
|
||||
id: `${edge.id}-b-${id.slice(0, 8)}`,
|
||||
source: id,
|
||||
sourceHandle: "outlet",
|
||||
target: edge.target,
|
||||
targetHandle: edge.targetHandle ?? undefined,
|
||||
animated: false,
|
||||
type: edgeType,
|
||||
},
|
||||
];
|
||||
|
||||
return { node, edgesToAdd, edgeIdToRemove: edge.id };
|
||||
}
|
||||
|
||||
export function mediaCompatible(pipe: MediaKind, edge: MediaKind): boolean {
|
||||
if (edge === "unknown" || pipe === "unknown") return true;
|
||||
return pipe === edge;
|
||||
}
|
||||
|
||||
export function circuitFromEdge(
|
||||
edge: Edge,
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
): number {
|
||||
const s = nodes.find((n) => n.id === edge.source);
|
||||
return s?.data.circuit ?? 0;
|
||||
}
|
||||
|
||||
export function validatePipeOnEdge(
|
||||
pipeType: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
edge: Edge,
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
): { ok: boolean; reason?: string } {
|
||||
const pipeKind = pipeMediaKind(pipeType, params);
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const edgeKind = mediaForEdge(
|
||||
byId.get(edge.source),
|
||||
edge.sourceHandle,
|
||||
byId.get(edge.target),
|
||||
edge.targetHandle,
|
||||
);
|
||||
if (!mediaCompatible(pipeKind, edgeKind)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Ce conduit (${pipeKind}) ne correspond pas à la ligne (${edgeKind}).`,
|
||||
};
|
||||
}
|
||||
const probe = mediaForPort(
|
||||
{
|
||||
data: {
|
||||
type: pipeType,
|
||||
name: "p",
|
||||
circuit: 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params,
|
||||
},
|
||||
},
|
||||
"inlet",
|
||||
);
|
||||
if (!mediaCompatible(probe, edgeKind)) {
|
||||
return { ok: false, reason: `Milieu incompatible (${probe} vs ${edgeKind}).` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
109
apps/web/src/lib/hxFamily.ts
Normal file
109
apps/web/src/lib/hxFamily.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Visual family for heat exchangers — drives glyph + node badge so DX /
|
||||
* flooded / plate / coil / MCHX are distinguishable at a glance.
|
||||
*/
|
||||
|
||||
export type HxFamilyId =
|
||||
| "dx"
|
||||
| "flooded"
|
||||
| "plate"
|
||||
| "coil"
|
||||
| "mchx"
|
||||
| "shell"
|
||||
| "generic";
|
||||
|
||||
export interface HxFamily {
|
||||
id: HxFamilyId;
|
||||
/** Short French badge on the canvas */
|
||||
badge: string;
|
||||
/** Palette / tooltip line */
|
||||
label: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const FAMILIES: Record<HxFamilyId, HxFamily> = {
|
||||
dx: {
|
||||
id: "dx",
|
||||
badge: "DX",
|
||||
label: "Détente directe",
|
||||
accent: "#1565c0",
|
||||
},
|
||||
flooded: {
|
||||
id: "flooded",
|
||||
badge: "Noyé",
|
||||
label: "Calandre noyée",
|
||||
accent: "#0d7377",
|
||||
},
|
||||
plate: {
|
||||
id: "plate",
|
||||
badge: "Plaques",
|
||||
label: "Échangeur à plaques",
|
||||
accent: "#1a56a8",
|
||||
},
|
||||
coil: {
|
||||
id: "coil",
|
||||
badge: "Coil",
|
||||
label: "Batterie à ailettes",
|
||||
accent: "#c27803",
|
||||
},
|
||||
mchx: {
|
||||
id: "mchx",
|
||||
badge: "MCHX",
|
||||
label: "Microcanaux",
|
||||
accent: "#d97706",
|
||||
},
|
||||
shell: {
|
||||
id: "shell",
|
||||
badge: "Tubes",
|
||||
label: "Calandre / tubes",
|
||||
accent: "#c0392b",
|
||||
},
|
||||
generic: {
|
||||
id: "generic",
|
||||
badge: "HX",
|
||||
label: "Échangeur",
|
||||
accent: "#475569",
|
||||
},
|
||||
};
|
||||
|
||||
/** Returns a visual family for heat-exchanger types; null for non-HX. */
|
||||
export function hxFamily(type: string): HxFamily | null {
|
||||
if (type.startsWith("Bphx") || type.includes("Bphx")) return FAMILIES.plate;
|
||||
if (type.includes("Flooded")) return FAMILIES.flooded;
|
||||
if (type.includes("Mchx")) return FAMILIES.mchx;
|
||||
if (
|
||||
type.includes("FinCoil") ||
|
||||
type.includes("AirCooled") ||
|
||||
type === "CondenserCoil" ||
|
||||
type === "EvaporatorCoil"
|
||||
) {
|
||||
return FAMILIES.coil;
|
||||
}
|
||||
if (type === "Evaporator") return FAMILIES.dx;
|
||||
if (type === "Condenser") return FAMILIES.shell;
|
||||
if (type === "HeatExchanger") return FAMILIES.generic;
|
||||
if (type.includes("Condenser") || type.includes("Evaporator")) return FAMILIES.shell;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Glyph key used by ComponentIcon — finer than the old include-based map. */
|
||||
export function hxGlyphKey(type: string): string | null {
|
||||
const f = hxFamily(type);
|
||||
if (!f) return null;
|
||||
switch (f.id) {
|
||||
case "plate":
|
||||
return "hx_plate";
|
||||
case "flooded":
|
||||
return "hx_flooded";
|
||||
case "coil":
|
||||
return "hx_coil";
|
||||
case "mchx":
|
||||
return "hx_mchx";
|
||||
case "dx":
|
||||
return "hx_dx";
|
||||
case "shell":
|
||||
return type.includes("Evaporator") ? "hx_shell_evap" : "hx_shell_cond";
|
||||
default:
|
||||
return "hx";
|
||||
}
|
||||
}
|
||||
48
apps/web/src/lib/mediaStyle.test.ts
Normal file
48
apps/web/src/lib/mediaStyle.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import { classifyFluidName, mediaForEdge, mediaForPort } from "./mediaStyle";
|
||||
|
||||
function node(
|
||||
type: string,
|
||||
name: string,
|
||||
params: Record<string, number | string | boolean> = {},
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id: name,
|
||||
type: "entropykNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, circuit: 0, rotation: 0, params },
|
||||
};
|
||||
}
|
||||
|
||||
describe("mediaStyle", () => {
|
||||
it("classifies fluid names", () => {
|
||||
expect(classifyFluidName("R134a")).toBe("refrigerant");
|
||||
expect(classifyFluidName("Water")).toBe("water");
|
||||
expect(classifyFluidName("MEG")).toBe("water");
|
||||
expect(classifyFluidName("Air")).toBe("air");
|
||||
});
|
||||
|
||||
it("marks brine source ports as water and compressor as refrigerant", () => {
|
||||
expect(mediaForPort(node("BrineSource", "evap_water_in"), "outlet")).toBe("water");
|
||||
expect(mediaForPort(node("IsentropicCompressor", "comp"), "outlet")).toBe("refrigerant");
|
||||
expect(mediaForPort(node("AirSource", "oa"), "outlet")).toBe("air");
|
||||
});
|
||||
|
||||
it("marks flooded secondary ports as water and fin-coil secondary as air", () => {
|
||||
expect(
|
||||
mediaForPort(node("FloodedEvaporator", "evap"), "secondary_inlet"),
|
||||
).toBe("water");
|
||||
expect(
|
||||
mediaForPort(node("FinCoilCondenser", "cond"), "secondary_inlet"),
|
||||
).toBe("air");
|
||||
expect(mediaForPort(node("FinCoilCondenser", "cond"), "inlet")).toBe("refrigerant");
|
||||
});
|
||||
|
||||
it("colours an edge from brine source to flooded secondary as water", () => {
|
||||
const src = node("BrineSource", "evap_water_in");
|
||||
const tgt = node("FloodedEvaporator", "evap");
|
||||
expect(mediaForEdge(src, "outlet", tgt, "secondary_inlet")).toBe("water");
|
||||
});
|
||||
});
|
||||
204
apps/web/src/lib/mediaStyle.ts
Normal file
204
apps/web/src/lib/mediaStyle.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Modelica / TIL-style medium colours for diagram wires and connectors.
|
||||
*
|
||||
* Convention (TIL Media + common refrigeration Modelica libraries):
|
||||
* - Refrigerant (VLE) … green
|
||||
* - Water / brine / glycol … blue
|
||||
* - Air / gas …………… yellow / amber
|
||||
*
|
||||
* Buildings/MSL do not hard-code medium colours on connectors; TIL and most
|
||||
* HVAC libraries do — we follow that engineering diagram practice.
|
||||
*/
|
||||
|
||||
import { isSecondaryPort } from "./componentMeta";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type MediaKind = "refrigerant" | "water" | "air" | "unknown";
|
||||
|
||||
/** Stroke / connector fill colours (TIL-like). */
|
||||
export const MEDIA_COLOR: Record<MediaKind, string> = {
|
||||
refrigerant: "#2e7d32", // green — two-phase / VLE
|
||||
water: "#1565c0", // blue — liquid HTF (Modelica connector blue)
|
||||
air: "#f9a825", // amber/yellow — gas / moist air
|
||||
unknown: "#36475a",
|
||||
};
|
||||
|
||||
export const MEDIA_LABEL: Record<MediaKind, string> = {
|
||||
refrigerant: "Frigorigène",
|
||||
water: "Eau / brine",
|
||||
air: "Air",
|
||||
unknown: "Autre",
|
||||
};
|
||||
|
||||
const AIR_TYPES = new Set([
|
||||
"AirSource",
|
||||
"AirSink",
|
||||
"Fan",
|
||||
"FinCoilCondenser",
|
||||
"AirCooledCondenser",
|
||||
"MchxCondenserCoil",
|
||||
]);
|
||||
|
||||
const WATER_TYPES = new Set([
|
||||
"BrineSource",
|
||||
"BrineSink",
|
||||
"Pump",
|
||||
"FreeCoolingExchanger",
|
||||
"WaterPipe",
|
||||
"PipeWater",
|
||||
]);
|
||||
|
||||
const AIR_PIPE_TYPES = new Set(["AirDuct", "PipeAir"]);
|
||||
|
||||
const REFRIGERANT_TYPES = new Set([
|
||||
"RefrigerantSource",
|
||||
"RefrigerantSink",
|
||||
"IsentropicCompressor",
|
||||
"Compressor",
|
||||
"ScrewEconomizerCompressor",
|
||||
"CentrifugalCompressor",
|
||||
"IsenthalpicExpansionValve",
|
||||
"ExpansionValve",
|
||||
"CapillaryTube",
|
||||
"ReversingValve",
|
||||
"BypassValve",
|
||||
"FloodedEvaporator",
|
||||
"BphxEvaporator",
|
||||
"BphxCondenser",
|
||||
"Condenser",
|
||||
"Evaporator",
|
||||
"CondenserCoil",
|
||||
"EvaporatorCoil",
|
||||
"Drum",
|
||||
"Pipe",
|
||||
"RefrigerantPipe",
|
||||
"FlowSplitter",
|
||||
"FlowMerger",
|
||||
]);
|
||||
|
||||
/** Map a fluid name string to a medium kind. */
|
||||
export function classifyFluidName(raw: string | undefined | null): MediaKind | null {
|
||||
if (!raw) return null;
|
||||
const f = String(raw).trim().toLowerCase();
|
||||
if (!f) return null;
|
||||
if (
|
||||
f === "air" ||
|
||||
f === "moistair" ||
|
||||
f === "moist_air" ||
|
||||
f.startsWith("air")
|
||||
) {
|
||||
return "air";
|
||||
}
|
||||
if (
|
||||
f === "water" ||
|
||||
f === "meg" ||
|
||||
f === "mpg" ||
|
||||
f.includes("glycol") ||
|
||||
f.includes("brine") ||
|
||||
f === "incompressiblewater"
|
||||
) {
|
||||
return "water";
|
||||
}
|
||||
// R134a, R410A, CO2, ammonia, …
|
||||
if (
|
||||
f.startsWith("r") ||
|
||||
f === "co2" ||
|
||||
f === "r744" ||
|
||||
f.includes("ammonia") ||
|
||||
f === "nh3" ||
|
||||
f === "propane" ||
|
||||
f === "r290"
|
||||
) {
|
||||
return "refrigerant";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type PortHost =
|
||||
| Node<EntropykNodeData>
|
||||
| { data: EntropykNodeData }
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Infer the medium on a specific port of a component (Modelica Medium package).
|
||||
*/
|
||||
export function mediaForPort(
|
||||
node: PortHost,
|
||||
port: string | null | undefined,
|
||||
): MediaKind {
|
||||
if (!node) return "unknown";
|
||||
const type = node.data.type;
|
||||
const params = node.data.params ?? {};
|
||||
const p = port ?? "";
|
||||
|
||||
if (type === "SaturatedController" || type === "Placeholder") return "unknown";
|
||||
|
||||
// Explicit 4-port HX fluid ids
|
||||
if (p === "hot_inlet" || p === "hot_outlet") {
|
||||
return classifyFluidName(String(params.hot_fluid_id ?? params.hot_fluid ?? "")) ?? "water";
|
||||
}
|
||||
if (p === "cold_inlet" || p === "cold_outlet") {
|
||||
return classifyFluidName(String(params.cold_fluid_id ?? params.cold_fluid ?? "")) ?? "air";
|
||||
}
|
||||
|
||||
// Secondary / HTF side of refrigerant HX
|
||||
if (isSecondaryPort(p)) {
|
||||
if (AIR_TYPES.has(type)) return "air";
|
||||
const sec = classifyFluidName(
|
||||
String(params.secondary_fluid ?? params.fluid ?? ""),
|
||||
);
|
||||
if (sec) return sec;
|
||||
// Flooded / BPHX / generic condenser-evaporator → water loop by default
|
||||
return "water";
|
||||
}
|
||||
|
||||
// Dedicated pipe / duct components (palette types)
|
||||
if (AIR_PIPE_TYPES.has(type)) return "air";
|
||||
if (type === "WaterPipe" || type === "PipeWater") return "water";
|
||||
if (type === "RefrigerantPipe") return "refrigerant";
|
||||
if (type === "Pipe") {
|
||||
return classifyFluidName(String(params.fluid ?? "")) ?? "refrigerant";
|
||||
}
|
||||
|
||||
// Whole-component media (sources, fans, pumps)
|
||||
if (AIR_TYPES.has(type) && !isSecondaryPort(p) && type !== "FinCoilCondenser" && type !== "AirCooledCondenser" && type !== "MchxCondenserCoil") {
|
||||
return "air";
|
||||
}
|
||||
if (type === "Fan") return "air";
|
||||
if (WATER_TYPES.has(type)) {
|
||||
return classifyFluidName(String(params.fluid ?? "")) ?? "water";
|
||||
}
|
||||
if (type === "AirSource" || type === "AirSink") return "air";
|
||||
if (type === "BrineSource" || type === "BrineSink") return "water";
|
||||
|
||||
// Air-cooled coils: primary ports are refrigerant, secondary already handled
|
||||
if (AIR_TYPES.has(type)) return "refrigerant";
|
||||
|
||||
if (REFRIGERANT_TYPES.has(type)) {
|
||||
return classifyFluidName(String(params.fluid ?? params.refrigerant ?? "")) ?? "refrigerant";
|
||||
}
|
||||
|
||||
// HeatExchanger primary-style ports without hot_/cold_ prefix
|
||||
if (type === "HeatExchanger") {
|
||||
return classifyFluidName(String(params.hot_fluid_id ?? "")) ?? "water";
|
||||
}
|
||||
|
||||
return "refrigerant";
|
||||
}
|
||||
|
||||
/** Edge medium: prefer the upstream (source) port, fall back to target. */
|
||||
export function mediaForEdge(
|
||||
source: Node<EntropykNodeData> | undefined,
|
||||
sourceHandle: string | null | undefined,
|
||||
target: Node<EntropykNodeData> | undefined,
|
||||
targetHandle: string | null | undefined,
|
||||
): MediaKind {
|
||||
const a = mediaForPort(source, sourceHandle);
|
||||
if (a !== "unknown") return a;
|
||||
return mediaForPort(target, targetHandle);
|
||||
}
|
||||
|
||||
export function mediaColor(kind: MediaKind): string {
|
||||
return MEDIA_COLOR[kind];
|
||||
}
|
||||
113
apps/web/src/lib/multiRun.test.ts
Normal file
113
apps/web/src/lib/multiRun.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import {
|
||||
applyOverride,
|
||||
buildSweepCases,
|
||||
defaultSweepsFromDiagram,
|
||||
discoverSweepTargets,
|
||||
parseValueList,
|
||||
suggestValues,
|
||||
} from "./multiRun";
|
||||
|
||||
function node(
|
||||
type: string,
|
||||
name: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Node<EntropykNodeData> {
|
||||
return {
|
||||
id: name,
|
||||
type: "entropykNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type, name, circuit: 0, rotation: 0, params },
|
||||
};
|
||||
}
|
||||
|
||||
describe("multiRun", () => {
|
||||
it("parses value lists", () => {
|
||||
expect(parseValueList("1, 2; 3\n4")).toEqual(["1", "2", "3", "4"]);
|
||||
});
|
||||
|
||||
it("applies brine water temperature overrides by component name", () => {
|
||||
const base = {
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "BrineSource", name: "evap_water_in", t_set_c: 12 },
|
||||
{ type: "BrineSource", name: "cond_water_in", t_set_c: 30 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const next = applyOverride(base, "evap_water_in.t_set_c", "10", "scalar") as typeof base;
|
||||
expect(next.circuits[0].components[0].t_set_c).toBe(10);
|
||||
expect(next.circuits[0].components[1].t_set_c).toBe(30);
|
||||
});
|
||||
|
||||
it("discovers water temperatures from BrineSource nodes first", () => {
|
||||
const nodes = [
|
||||
node("FloodedEvaporator", "evap", { ua: 8000 }),
|
||||
node("BrineSource", "evap_water_in", { t_set_c: 12, m_flow_kg_s: 0.5, p_set_bar: 2 }),
|
||||
node("BrineSource", "cond_water_in", { t_set_c: 30, m_flow_kg_s: 0.6, p_set_bar: 2 }),
|
||||
];
|
||||
const targets = discoverSweepTargets(nodes, "R134a");
|
||||
const paths = targets.map((t) => t.path);
|
||||
expect(paths).toContain("evap_water_in.t_set_c");
|
||||
expect(paths).toContain("cond_water_in.t_set_c");
|
||||
expect(paths.indexOf("evap_water_in.t_set_c")).toBeLessThan(paths.indexOf("evap.ua"));
|
||||
expect(targets.find((t) => t.path === "evap_water_in.t_set_c")?.label).toMatch(/évaporateur/i);
|
||||
});
|
||||
|
||||
it("defaults multi-run axes to both water temperatures", () => {
|
||||
const nodes = [
|
||||
node("BrineSource", "evap_water_in", { t_set_c: 12, m_flow_kg_s: 0.5 }),
|
||||
node("BrineSource", "cond_water_in", { t_set_c: 30, m_flow_kg_s: 0.6 }),
|
||||
];
|
||||
const sweeps = defaultSweepsFromDiagram(nodes, "R134a");
|
||||
expect(sweeps).toHaveLength(2);
|
||||
expect(sweeps.map((s) => s.path).sort()).toEqual([
|
||||
"cond_water_in.t_set_c",
|
||||
"evap_water_in.t_set_c",
|
||||
].sort());
|
||||
expect(sweeps[0].valuesText).toContain(",");
|
||||
});
|
||||
|
||||
it("suggests temperature steps around current", () => {
|
||||
expect(suggestValues(12, "t_set_c")).toBe("10, 12, 14");
|
||||
});
|
||||
|
||||
it("builds cartesian product of water temp sweeps", () => {
|
||||
const base = {
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
components: [
|
||||
{ name: "evap_water_in", t_set_c: 12 },
|
||||
{ name: "cond_water_in", t_set_c: 30 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const cases = buildSweepCases(base, [
|
||||
{
|
||||
path: "evap_water_in.t_set_c",
|
||||
label: "T évap",
|
||||
kind: "scalar",
|
||||
valuesText: "10, 12",
|
||||
},
|
||||
{
|
||||
path: "cond_water_in.t_set_c",
|
||||
label: "T cond",
|
||||
kind: "scalar",
|
||||
valuesText: "30, 35",
|
||||
},
|
||||
]);
|
||||
expect(cases).toHaveLength(4);
|
||||
const cfg = cases.find((c) => c.label.includes("10") && c.label.includes("35"))
|
||||
?.config as typeof base;
|
||||
expect(cfg.circuits[0].components[0].t_set_c).toBe(10);
|
||||
expect(cfg.circuits[0].components[1].t_set_c).toBe(35);
|
||||
});
|
||||
});
|
||||
411
apps/web/src/lib/multiRun.ts
Normal file
411
apps/web/src/lib/multiRun.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Parallel multi-run helpers: discover sweepable params from the diagram,
|
||||
* clone ScenarioConfig, and solve cases in parallel.
|
||||
*/
|
||||
|
||||
import { simulate, type SimulationResult } from "./api";
|
||||
import { COMPONENT_BY_TYPE } from "./componentMeta";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type SweepKind = "scalar" | "fluid";
|
||||
|
||||
export interface SweepSpec {
|
||||
/** `fluid` or `componentName.param` (CLI-flattened JSON). */
|
||||
path: string;
|
||||
label: string;
|
||||
kind: SweepKind;
|
||||
/** Comma / newline separated values. */
|
||||
valuesText: string;
|
||||
}
|
||||
|
||||
export interface SweepTarget {
|
||||
path: string;
|
||||
label: string;
|
||||
kind: SweepKind;
|
||||
group: "boundaries" | "thermal" | "machine" | "global";
|
||||
/** Current value on the diagram (for suggested ranges). */
|
||||
current?: number | string | boolean;
|
||||
unit?: string;
|
||||
/** Component display name (empty for global). */
|
||||
componentName?: string;
|
||||
componentType?: string;
|
||||
paramKey?: string;
|
||||
}
|
||||
|
||||
export interface MultiRunCase {
|
||||
id: string;
|
||||
label: string;
|
||||
config: unknown;
|
||||
overrides: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface MultiRunResult {
|
||||
case: MultiRunCase;
|
||||
ok: boolean;
|
||||
result?: SimulationResult;
|
||||
error?: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/** Params engineers typically sweep on a cycle. */
|
||||
const SWEEP_PARAM_PRIORITY: Record<string, { group: SweepTarget["group"]; rank: number }> = {
|
||||
t_set_c: { group: "boundaries", rank: 10 },
|
||||
t_dry_c: { group: "boundaries", rank: 11 },
|
||||
oat_k: { group: "boundaries", rank: 12 },
|
||||
m_flow_kg_s: { group: "boundaries", rank: 20 },
|
||||
air_face_velocity_m_s: { group: "boundaries", rank: 21 },
|
||||
ua: { group: "thermal", rank: 30 },
|
||||
opening: { group: "machine", rank: 40 },
|
||||
speed_ratio: { group: "machine", rank: 41 },
|
||||
frequency_hz: { group: "machine", rank: 42 },
|
||||
speed_rpm: { group: "machine", rank: 43 },
|
||||
slide_valve_position: { group: "machine", rank: 44 },
|
||||
volume_index: { group: "machine", rank: 45 },
|
||||
isentropic_efficiency: { group: "machine", rank: 46 },
|
||||
};
|
||||
|
||||
const BOUNDARY_TYPES = new Set([
|
||||
"BrineSource",
|
||||
"AirSource",
|
||||
"RefrigerantSource",
|
||||
]);
|
||||
|
||||
/** Parse "1, 2, 3" or multiline into string tokens. */
|
||||
export function parseValueList(text: string): string[] {
|
||||
return text
|
||||
.split(/[\n,;]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function humanParamLabel(type: string, paramKey: string, unit?: string): string {
|
||||
const meta = COMPONENT_BY_TYPE[type]?.params.find((p) => p.key === paramKey);
|
||||
const base = meta?.label ?? paramKey;
|
||||
const u = unit ?? meta?.unit;
|
||||
return u ? `${base} (${u})` : base;
|
||||
}
|
||||
|
||||
function friendlyComponentRole(type: string, name: string): string {
|
||||
if (type === "BrineSource") {
|
||||
if (/evap/i.test(name)) return "Eau évaporateur";
|
||||
if (/cond/i.test(name)) return "Eau condenseur";
|
||||
return `Source eau « ${name} »`;
|
||||
}
|
||||
if (type === "AirSource") {
|
||||
if (/cond|oat|outdoor/i.test(name)) return "Air extérieur";
|
||||
if (/evap|indoor/i.test(name)) return "Air intérieur";
|
||||
return `Source air « ${name} »`;
|
||||
}
|
||||
const label = COMPONENT_BY_TYPE[type]?.label ?? type;
|
||||
return `${label} « ${name} »`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover sweepable parameters from the live diagram.
|
||||
* Boundaries (water/air T) come first — that's what engineers sweep for ratings.
|
||||
*/
|
||||
export function discoverSweepTargets(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
fluid?: string,
|
||||
): SweepTarget[] {
|
||||
const targets: SweepTarget[] = [
|
||||
{
|
||||
path: "fluid",
|
||||
label: "Fluide frigorigène",
|
||||
kind: "fluid",
|
||||
group: "global",
|
||||
current: fluid,
|
||||
},
|
||||
];
|
||||
|
||||
for (const node of nodes) {
|
||||
const type = node.data.type;
|
||||
const name = node.data.name;
|
||||
if (!type || !name || type === "SaturatedController") continue;
|
||||
const params = node.data.params ?? {};
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (key.startsWith("__")) continue;
|
||||
const prio = SWEEP_PARAM_PRIORITY[key];
|
||||
if (!prio) continue;
|
||||
// Prefer live boundary setpoints over rating scalars on HX when both exist.
|
||||
if (
|
||||
(key === "secondary_inlet_temp_c" || key === "secondary_mass_flow_kg_s") &&
|
||||
!BOUNDARY_TYPES.has(type)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const paramMeta = meta?.params.find((p) => p.key === key);
|
||||
const role = friendlyComponentRole(type, name);
|
||||
const paramLabel = humanParamLabel(type, key, paramMeta?.unit);
|
||||
targets.push({
|
||||
path: `${name}.${key}`,
|
||||
label: `${role} — ${paramLabel}`,
|
||||
kind: typeof val === "string" && key === "fluid" ? "fluid" : "scalar",
|
||||
group: BOUNDARY_TYPES.has(type) ? "boundaries" : prio.group,
|
||||
current: val,
|
||||
unit: paramMeta?.unit,
|
||||
componentName: name,
|
||||
componentType: type,
|
||||
paramKey: key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
targets.sort((a, b) => {
|
||||
const order = { boundaries: 0, thermal: 1, machine: 2, global: 3 };
|
||||
const ga = order[a.group];
|
||||
const gb = order[b.group];
|
||||
if (ga !== gb) return ga - gb;
|
||||
const ra = a.paramKey ? (SWEEP_PARAM_PRIORITY[a.paramKey]?.rank ?? 99) : 0;
|
||||
const rb = b.paramKey ? (SWEEP_PARAM_PRIORITY[b.paramKey]?.rank ?? 99) : 0;
|
||||
if (ra !== rb) return ra - rb;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
/** Suggest a small sweep around the current numeric value. */
|
||||
export function suggestValues(current: number | string | boolean | undefined, paramKey?: string): string {
|
||||
if (typeof current !== "number" || !Number.isFinite(current)) {
|
||||
if (paramKey === "fluid" || current === undefined) return "R134a, R410A";
|
||||
return String(current ?? "");
|
||||
}
|
||||
if (paramKey === "t_set_c" || paramKey === "t_dry_c") {
|
||||
const step = 2;
|
||||
return [current - step, current, current + step].map((v) => String(v)).join(", ");
|
||||
}
|
||||
if (paramKey === "oat_k") {
|
||||
return [current - 5, current, current + 5].map((v) => String(v)).join(", ");
|
||||
}
|
||||
if (paramKey === "ua") {
|
||||
return [current * 0.8, current, current * 1.2]
|
||||
.map((v) => String(Math.round(v)))
|
||||
.join(", ");
|
||||
}
|
||||
if (paramKey === "m_flow_kg_s" || paramKey === "opening" || paramKey === "speed_ratio") {
|
||||
const a = Math.max(current * 0.8, 0);
|
||||
const b = current;
|
||||
const c = current * 1.2;
|
||||
return [a, b, c].map((v) => (Number.isInteger(v) ? String(v) : v.toFixed(3))).join(", ");
|
||||
}
|
||||
return String(current);
|
||||
}
|
||||
|
||||
/** Default axes when opening Multi-run: both water temperatures if present. */
|
||||
export function defaultSweepsFromDiagram(
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
fluid?: string,
|
||||
): SweepSpec[] {
|
||||
const targets = discoverSweepTargets(nodes, fluid);
|
||||
const waterTemps = targets.filter(
|
||||
(t) =>
|
||||
t.componentType === "BrineSource" &&
|
||||
t.paramKey === "t_set_c",
|
||||
);
|
||||
if (waterTemps.length >= 1) {
|
||||
return waterTemps.slice(0, 2).map((t) => ({
|
||||
path: t.path,
|
||||
label: t.label,
|
||||
kind: t.kind,
|
||||
valuesText: suggestValues(t.current, t.paramKey),
|
||||
}));
|
||||
}
|
||||
const airTemps = targets.filter(
|
||||
(t) => t.componentType === "AirSource" && t.paramKey === "t_dry_c",
|
||||
);
|
||||
if (airTemps.length >= 1) {
|
||||
return airTemps.slice(0, 2).map((t) => ({
|
||||
path: t.path,
|
||||
label: t.label,
|
||||
kind: t.kind,
|
||||
valuesText: suggestValues(t.current, t.paramKey),
|
||||
}));
|
||||
}
|
||||
const first = targets.find((t) => t.path !== "fluid") ?? targets[0];
|
||||
return [
|
||||
{
|
||||
path: first.path,
|
||||
label: first.label,
|
||||
kind: first.kind,
|
||||
valuesText: suggestValues(first.current, first.paramKey),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a parameter on a named component: `evap_water_in.t_set_c`.
|
||||
* Component names may contain underscores.
|
||||
*/
|
||||
export function applyOverride(
|
||||
config: unknown,
|
||||
path: string,
|
||||
raw: string,
|
||||
kind: SweepKind,
|
||||
): unknown {
|
||||
const value: string | number =
|
||||
kind === "fluid" ? raw : Number.isFinite(Number(raw)) ? Number(raw) : raw;
|
||||
|
||||
if (path === "fluid") {
|
||||
const c = structuredClone(config) as Record<string, unknown>;
|
||||
c.fluid = value;
|
||||
return c;
|
||||
}
|
||||
|
||||
const dot = path.indexOf(".");
|
||||
if (dot > 0) {
|
||||
const name = path.slice(0, dot);
|
||||
const param = path.slice(dot + 1);
|
||||
if (name && param && !param.includes(".")) {
|
||||
const c = structuredClone(config) as {
|
||||
circuits?: Array<{
|
||||
components?: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
};
|
||||
let hit = false;
|
||||
for (const circuit of c.circuits ?? []) {
|
||||
for (const comp of circuit.components ?? []) {
|
||||
if (comp.name === name) {
|
||||
comp[param] = value;
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hit) {
|
||||
// Keep config but caller may surface a warning via empty hit.
|
||||
}
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
return structuredClone(config);
|
||||
}
|
||||
|
||||
/** Cartesian product of sweep axes → run cases. */
|
||||
export function buildSweepCases(
|
||||
baseConfig: unknown,
|
||||
sweeps: SweepSpec[],
|
||||
): MultiRunCase[] {
|
||||
const axes = sweeps
|
||||
.map((s) => ({
|
||||
...s,
|
||||
values: parseValueList(s.valuesText),
|
||||
}))
|
||||
.filter((s) => s.values.length > 0);
|
||||
|
||||
if (axes.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: "base",
|
||||
label: "base",
|
||||
config: structuredClone(baseConfig),
|
||||
overrides: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let combos: Array<Record<string, string>> = [{}];
|
||||
for (const axis of axes) {
|
||||
const next: Array<Record<string, string>> = [];
|
||||
for (const prev of combos) {
|
||||
for (const v of axis.values) {
|
||||
next.push({ ...prev, [axis.path]: v });
|
||||
}
|
||||
}
|
||||
combos = next;
|
||||
}
|
||||
|
||||
return combos.map((overrides, i) => {
|
||||
let cfg = structuredClone(baseConfig);
|
||||
const labels: string[] = [];
|
||||
for (const axis of axes) {
|
||||
const raw = overrides[axis.path];
|
||||
cfg = applyOverride(cfg, axis.path, raw, axis.kind);
|
||||
labels.push(`${axis.label || axis.path}=${raw}`);
|
||||
}
|
||||
return {
|
||||
id: `case-${i + 1}`,
|
||||
label: labels.join(" · "),
|
||||
config: cfg,
|
||||
overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Run cases with a concurrency limit (default 4). */
|
||||
export async function runParallel(
|
||||
cases: MultiRunCase[],
|
||||
options?: { concurrency?: number; onProgress?: (done: number, total: number) => void },
|
||||
): Promise<MultiRunResult[]> {
|
||||
const concurrency = Math.max(1, options?.concurrency ?? 4);
|
||||
const total = cases.length;
|
||||
const results: MultiRunResult[] = new Array(total);
|
||||
let next = 0;
|
||||
let done = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < total) {
|
||||
const idx = next++;
|
||||
const c = cases[idx];
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const resp = await simulate(c.config);
|
||||
results[idx] = {
|
||||
case: c,
|
||||
ok: !!resp.ok && !!resp.result,
|
||||
result: resp.result,
|
||||
error: resp.error,
|
||||
durationMs: performance.now() - t0,
|
||||
};
|
||||
} catch (e) {
|
||||
results[idx] = {
|
||||
case: c,
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
durationMs: performance.now() - t0,
|
||||
};
|
||||
}
|
||||
done++;
|
||||
options?.onProgress?.(done, total);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, total) }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Compact KPIs for comparison tables. */
|
||||
export function extractKpis(result?: SimulationResult): {
|
||||
status: string;
|
||||
cop: number | null;
|
||||
qCoolKw: number | null;
|
||||
powerKw: number | null;
|
||||
iterations: number | null;
|
||||
} {
|
||||
if (!result) {
|
||||
return { status: "error", cop: null, qCoolKw: null, powerKw: null, iterations: null };
|
||||
}
|
||||
const p = result.performance;
|
||||
const cop =
|
||||
p?.cop ??
|
||||
p?.cop_cooling ??
|
||||
(p?.cooling_capacity_w != null && p?.compressor_power_w
|
||||
? p.cooling_capacity_w / p.compressor_power_w
|
||||
: null);
|
||||
const qCoolKw =
|
||||
p?.q_cooling_kw ??
|
||||
(p?.cooling_capacity_w != null ? p.cooling_capacity_w / 1000 : null);
|
||||
const powerKw =
|
||||
p?.compressor_power_kw ??
|
||||
(p?.compressor_power_w != null ? p.compressor_power_w / 1000 : null);
|
||||
return {
|
||||
status: result.status,
|
||||
cop: cop != null && Number.isFinite(cop) ? cop : null,
|
||||
qCoolKw: qCoolKw != null && Number.isFinite(qCoolKw) ? qCoolKw : null,
|
||||
powerKw: powerKw != null && Number.isFinite(powerKw) ? powerKw : null,
|
||||
iterations: result.iterations ?? result.convergence?.iterations ?? null,
|
||||
};
|
||||
}
|
||||
28
apps/web/src/lib/orientation.test.ts
Normal file
28
apps/web/src/lib/orientation.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
effectiveSide,
|
||||
orientationAlongEdge,
|
||||
rotateSide,
|
||||
} from "./orientation";
|
||||
|
||||
describe("orientation", () => {
|
||||
it("rotates sides clockwise", () => {
|
||||
expect(rotateSide("left", 90)).toBe("top");
|
||||
expect(rotateSide("left", 180)).toBe("right");
|
||||
expect(rotateSide("left", 270)).toBe("bottom");
|
||||
});
|
||||
|
||||
it("applies Modelica rotate then flip", () => {
|
||||
expect(effectiveSide("left", 0, false, false)).toBe("left");
|
||||
expect(effectiveSide("left", 180, false, false)).toBe("right");
|
||||
expect(effectiveSide("left", 0, true, false)).toBe("right");
|
||||
expect(effectiveSide("top", 0, false, true)).toBe("bottom");
|
||||
});
|
||||
|
||||
it("picks rotation along an edge", () => {
|
||||
expect(orientationAlongEdge({ x: 0, y: 0 }, { x: 100, y: 0 })).toBe(0);
|
||||
expect(orientationAlongEdge({ x: 100, y: 0 }, { x: 0, y: 0 })).toBe(180);
|
||||
expect(orientationAlongEdge({ x: 0, y: 0 }, { x: 0, y: 100 })).toBe(90);
|
||||
expect(orientationAlongEdge({ x: 0, y: 100 }, { x: 0, y: 0 })).toBe(270);
|
||||
});
|
||||
});
|
||||
78
apps/web/src/lib/orientation.ts
Normal file
78
apps/web/src/lib/orientation.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Modelica / Dymola-style component orientation.
|
||||
*
|
||||
* Handles must NOT be CSS-rotated — React Flow routes edges from Handle
|
||||
* `position` (Left/Right/Top/Bottom). We remap logical port sides through
|
||||
* rotation + flips so connectors stay on the correct box edge.
|
||||
*/
|
||||
|
||||
export type Side = "left" | "right" | "top" | "bottom";
|
||||
|
||||
export type RotationDeg = 0 | 90 | 180 | 270;
|
||||
|
||||
export function normaliseRotation(deg: number): RotationDeg {
|
||||
const n = ((Math.round(deg / 90) * 90) % 360 + 360) % 360;
|
||||
return n as RotationDeg;
|
||||
}
|
||||
|
||||
/** Rotate a side clockwise by 90° × steps. */
|
||||
export function rotateSide(side: Side, rotation: number): Side {
|
||||
const order: Side[] = ["left", "top", "right", "bottom"];
|
||||
const steps = ((Math.round(rotation / 90) % 4) + 4) % 4;
|
||||
const i = order.indexOf(side);
|
||||
return order[(i + steps) % 4];
|
||||
}
|
||||
|
||||
export function flipSideH(side: Side): Side {
|
||||
if (side === "left") return "right";
|
||||
if (side === "right") return "left";
|
||||
return side;
|
||||
}
|
||||
|
||||
export function flipSideV(side: Side): Side {
|
||||
if (side === "top") return "bottom";
|
||||
if (side === "bottom") return "top";
|
||||
return side;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective diagram side for a logical port side after Modelica transform:
|
||||
* rotate clockwise, then flip horizontal, then flip vertical.
|
||||
*/
|
||||
export function effectiveSide(
|
||||
logical: Side,
|
||||
rotation: number,
|
||||
flipH = false,
|
||||
flipV = false,
|
||||
): Side {
|
||||
let s = rotateSide(logical, rotation);
|
||||
if (flipH) s = flipSideH(s);
|
||||
if (flipV) s = flipSideV(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose rotation so a 2-port part (inlet=left, outlet=right at 0°)
|
||||
* faces along the edge from source → target.
|
||||
*/
|
||||
export function orientationAlongEdge(
|
||||
sourceCenter: { x: number; y: number },
|
||||
targetCenter: { x: number; y: number },
|
||||
): RotationDeg {
|
||||
const dx = targetCenter.x - sourceCenter.x;
|
||||
const dy = targetCenter.y - sourceCenter.y;
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
// Horizontal: 0° = flow left→right, 180° = right→left
|
||||
return dx >= 0 ? 0 : 180;
|
||||
}
|
||||
// Vertical: 90° CW puts inlet on top, outlet on bottom (flow down)
|
||||
return dy >= 0 ? 90 : 270;
|
||||
}
|
||||
|
||||
/** Top-left position so the node box is centered on `mid`. */
|
||||
export function centerNodePosition(
|
||||
mid: { x: number; y: number },
|
||||
size: { w: number; h: number },
|
||||
): { x: number; y: number } {
|
||||
return { x: mid.x - size.w / 2, y: mid.y - size.h / 2 };
|
||||
}
|
||||
504
apps/web/src/store/diagramStore.test.ts
Normal file
504
apps/web/src/store/diagramStore.test.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
useDiagramStore,
|
||||
snapToGridValue,
|
||||
snapPosition,
|
||||
normaliseRotation,
|
||||
GRID_SIZE,
|
||||
} from "./diagramStore";
|
||||
|
||||
function reset() {
|
||||
useDiagramStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodeId: null,
|
||||
fluid: "R410A",
|
||||
fluidBackend: "CoolProp",
|
||||
controls: [],
|
||||
snapToGrid: true,
|
||||
result: null,
|
||||
simulating: false,
|
||||
simError: null,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(reset);
|
||||
|
||||
describe("controls import", () => {
|
||||
it("preserves imported co-solved controls for the next simulation run", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [{ type: "IsentropicCompressor", name: "comp", liquid_injection: true }],
|
||||
edges: [],
|
||||
},
|
||||
],
|
||||
controls: [
|
||||
{
|
||||
type: "SaturatedController",
|
||||
id: "dgt_limiter",
|
||||
measure: { component: "comp", output: "temperature" },
|
||||
actuator: { component: "comp", factor: "injection", initial: 0.15, min: 0, max: 0.3 },
|
||||
target: 330,
|
||||
gain: -0.5,
|
||||
band: 5,
|
||||
alpha: 0.002,
|
||||
objectives: [
|
||||
{
|
||||
component: "comp",
|
||||
output: "temperature",
|
||||
setpoint: 365,
|
||||
gain: -0.05,
|
||||
combine: "min",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
solver: { strategy: "newton", max_iterations: 300, tolerance: 1e-6 },
|
||||
});
|
||||
|
||||
expect(useDiagramStore.getState().controls).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "dgt_limiter",
|
||||
actuator: expect.objectContaining({ factor: "injection", initial: 0.15 }),
|
||||
target: 330,
|
||||
}),
|
||||
]);
|
||||
expect(useDiagramStore.getState().nodes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
type: "SaturatedController",
|
||||
name: "dgt_limiter",
|
||||
params: expect.objectContaining({
|
||||
measure_component: "comp",
|
||||
actuator_factor: "injection",
|
||||
alpha: 0.002,
|
||||
objectives_json: JSON.stringify([
|
||||
{
|
||||
component: "comp",
|
||||
output: "temperature",
|
||||
setpoint: 365,
|
||||
gain: -0.05,
|
||||
combine: "min",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("grid helpers", () => {
|
||||
it("snaps a value to the nearest grid line", () => {
|
||||
expect(snapToGridValue(5, 12)).toBe(0);
|
||||
expect(snapToGridValue(7, 12)).toBe(12);
|
||||
expect(snapToGridValue(18, 12)).toBe(24);
|
||||
});
|
||||
|
||||
it("snaps an {x, y} position", () => {
|
||||
expect(snapPosition({ x: 7, y: 5 }, 12)).toEqual({ x: 12, y: 0 });
|
||||
});
|
||||
|
||||
it("normalises rotation to 0/90/180/270", () => {
|
||||
expect(normaliseRotation(0)).toBe(0);
|
||||
expect(normaliseRotation(90)).toBe(90);
|
||||
expect(normaliseRotation(360)).toBe(0);
|
||||
expect(normaliseRotation(-90)).toBe(270);
|
||||
expect(normaliseRotation(450)).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addComponent", () => {
|
||||
it("creates a node with defaults, rotation 0, and selects it", () => {
|
||||
useDiagramStore.getState().addComponent("Condenser", { x: 100, y: 100 });
|
||||
const { nodes, selectedNodeId } = useDiagramStore.getState();
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].data.type).toBe("Condenser");
|
||||
expect(nodes[0].data.rotation).toBe(0);
|
||||
expect(nodes[0].data.params.ua).toBe(5000);
|
||||
expect(selectedNodeId).toBe(nodes[0].id);
|
||||
});
|
||||
|
||||
it("snaps the drop position when snap is enabled", () => {
|
||||
useDiagramStore.getState().addComponent("Condenser", { x: 103, y: 97 });
|
||||
const n = useDiagramStore.getState().nodes[0];
|
||||
expect(n.position.x % GRID_SIZE).toBe(0);
|
||||
expect(n.position.y % GRID_SIZE).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the raw position when snap is disabled", () => {
|
||||
useDiagramStore.setState({ snapToGrid: false });
|
||||
useDiagramStore.getState().addComponent("Condenser", { x: 103, y: 97 });
|
||||
const n = useDiagramStore.getState().nodes[0];
|
||||
expect(n.position).toEqual({ x: 103, y: 97 });
|
||||
});
|
||||
|
||||
it("generates unique names per type", () => {
|
||||
const s = useDiagramStore.getState();
|
||||
s.addComponent("Condenser", { x: 0, y: 0 });
|
||||
s.addComponent("Condenser", { x: 0, y: 0 });
|
||||
const names = useDiagramStore.getState().nodes.map((n) => n.data.name);
|
||||
expect(new Set(names).size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("node mutations", () => {
|
||||
it("updates params, name, and circuit", () => {
|
||||
useDiagramStore.getState().addComponent("Condenser", { x: 0, y: 0 });
|
||||
const id = useDiagramStore.getState().nodes[0].id;
|
||||
const s = useDiagramStore.getState();
|
||||
s.updateNodeParams(id, { ua: 9999 });
|
||||
s.updateNodeName(id, "my_cond");
|
||||
s.updateNodeCircuit(id, 2);
|
||||
const n = useDiagramStore.getState().nodes[0];
|
||||
expect(n.data.params.ua).toBe(9999);
|
||||
expect(n.data.name).toBe("my_cond");
|
||||
expect(n.data.circuit).toBe(2);
|
||||
});
|
||||
|
||||
it("rotates clockwise and counter-clockwise with wrap-around", () => {
|
||||
useDiagramStore.getState().addComponent("Compressor", { x: 0, y: 0 });
|
||||
const id = useDiagramStore.getState().nodes[0].id;
|
||||
const { rotateNode } = useDiagramStore.getState();
|
||||
rotateNode(id, 1);
|
||||
expect(useDiagramStore.getState().nodes[0].data.rotation).toBe(90);
|
||||
rotateNode(id, 1);
|
||||
rotateNode(id, 1);
|
||||
rotateNode(id, 1);
|
||||
expect(useDiagramStore.getState().nodes[0].data.rotation).toBe(0);
|
||||
rotateNode(id, -1);
|
||||
expect(useDiagramStore.getState().nodes[0].data.rotation).toBe(270);
|
||||
});
|
||||
|
||||
it("flips horizontal and vertical like Modelica", () => {
|
||||
useDiagramStore.getState().addComponent("RefrigerantPipe", { x: 0, y: 0 });
|
||||
const id = useDiagramStore.getState().nodes[0].id;
|
||||
expect(useDiagramStore.getState().nodes[0].data.flipH).toBe(false);
|
||||
useDiagramStore.getState().flipNodeH(id);
|
||||
expect(useDiagramStore.getState().nodes[0].data.flipH).toBe(true);
|
||||
useDiagramStore.getState().flipNodeV(id);
|
||||
expect(useDiagramStore.getState().nodes[0].data.flipV).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connections and removal", () => {
|
||||
it("adds an edge on connect", () => {
|
||||
const s = useDiagramStore.getState();
|
||||
s.addComponent("IsentropicCompressor", { x: 0, y: 0 });
|
||||
s.addComponent("Condenser", { x: 200, y: 0 });
|
||||
const [a, b] = useDiagramStore.getState().nodes;
|
||||
useDiagramStore.getState().onConnect({
|
||||
source: a.id,
|
||||
target: b.id,
|
||||
sourceHandle: "outlet",
|
||||
targetHandle: "inlet",
|
||||
});
|
||||
const edges = useDiagramStore.getState().edges;
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0].source).toBe(a.id);
|
||||
expect(edges[0].target).toBe(b.id);
|
||||
});
|
||||
|
||||
it("removes a node along with its connected edges and clears selection", () => {
|
||||
const s = useDiagramStore.getState();
|
||||
s.addComponent("IsentropicCompressor", { x: 0, y: 0 });
|
||||
s.addComponent("Condenser", { x: 200, y: 0 });
|
||||
const [a, b] = useDiagramStore.getState().nodes;
|
||||
useDiagramStore.getState().onConnect({
|
||||
source: a.id,
|
||||
target: b.id,
|
||||
sourceHandle: "outlet",
|
||||
targetHandle: "inlet",
|
||||
});
|
||||
useDiagramStore.getState().setSelected(a.id);
|
||||
useDiagramStore.getState().removeNode(a.id);
|
||||
const st = useDiagramStore.getState();
|
||||
expect(st.nodes).toHaveLength(1);
|
||||
expect(st.edges).toHaveLength(0);
|
||||
expect(st.selectedNodeId).toBeNull();
|
||||
});
|
||||
|
||||
it("clears selection when the selected node is removed via onNodesChange", () => {
|
||||
useDiagramStore.getState().addComponent("Condenser", { x: 0, y: 0 });
|
||||
const id = useDiagramStore.getState().nodes[0].id;
|
||||
useDiagramStore.getState().setSelected(id);
|
||||
useDiagramStore.getState().onNodesChange([{ type: "remove", id }]);
|
||||
expect(useDiagramStore.getState().selectedNodeId).toBeNull();
|
||||
expect(useDiagramStore.getState().nodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleSnapToGrid", () => {
|
||||
it("flips the snap flag", () => {
|
||||
expect(useDiagramStore.getState().snapToGrid).toBe(true);
|
||||
useDiagramStore.getState().toggleSnapToGrid();
|
||||
expect(useDiagramStore.getState().snapToGrid).toBe(false);
|
||||
useDiagramStore.getState().toggleSnapToGrid();
|
||||
expect(useDiagramStore.getState().snapToGrid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadFromConfig and clear", () => {
|
||||
const config = {
|
||||
fluid: "R290",
|
||||
fluid_backend: "Test",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "IsentropicCompressor", name: "comp", isentropic_efficiency: 0.7 },
|
||||
{ type: "Condenser", name: "cond", ua: 5000 },
|
||||
],
|
||||
edges: [{ from: "comp:outlet", to: "cond:inlet" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("rebuilds nodes, edges, rotation and fluid from a config", () => {
|
||||
useDiagramStore.getState().loadFromConfig(config);
|
||||
const st = useDiagramStore.getState();
|
||||
expect(st.nodes).toHaveLength(2);
|
||||
expect(st.edges).toHaveLength(1);
|
||||
expect(st.fluid).toBe("R290");
|
||||
expect(st.fluidBackend).toBe("Test");
|
||||
for (const n of st.nodes) expect(n.data.rotation).toBe(0);
|
||||
// the params should be carried over as primitives
|
||||
const comp = st.nodes.find((n) => n.data.name === "comp");
|
||||
expect(comp?.data.params.isentropic_efficiency).toBe(0.7);
|
||||
});
|
||||
|
||||
it("ignores configs without circuits", () => {
|
||||
useDiagramStore.getState().addComponent("Condenser", { x: 0, y: 0 });
|
||||
useDiagramStore.getState().loadFromConfig({ foo: "bar" });
|
||||
expect(useDiagramStore.getState().nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("flattens subsystem instances from system model JSON", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
fluid_backend: "CoolProp",
|
||||
subsystems: {
|
||||
Circuit: {
|
||||
params: { ua_cond: 700 },
|
||||
components: [
|
||||
{ type: "IsentropicCompressor", name: "comp", isentropic_efficiency: 0.7 },
|
||||
{ type: "Condenser", name: "cond", ua: "$ua_cond" },
|
||||
],
|
||||
edges: [{ from: "comp:outlet", to: "cond:inlet" }],
|
||||
ports: { discharge: "comp:outlet" },
|
||||
},
|
||||
},
|
||||
instances: [
|
||||
{ of: "Circuit", name: "A", circuit: 0, params: { ua_cond: 800 } },
|
||||
{ of: "Circuit", name: "B", circuit: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const st = useDiagramStore.getState();
|
||||
expect(st.nodes).toHaveLength(4);
|
||||
expect(st.edges).toHaveLength(2);
|
||||
expect(st.nodes.map((n) => n.data.name)).toContain("A.comp");
|
||||
expect(st.nodes.map((n) => n.data.name)).toContain("B.cond");
|
||||
expect(st.nodes.find((n) => n.data.name === "A.cond")?.data.params.ua).toBe(800);
|
||||
expect(st.nodes.find((n) => n.data.name === "B.cond")?.data.params.ua).toBe(700);
|
||||
});
|
||||
|
||||
it("keeps explicit water circuits while flattening refrigerant subsystem instances", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 2,
|
||||
components: [
|
||||
{ type: "BrineSource", name: "water_in", t_set_c: 30 },
|
||||
{ type: "ThermalLoad", name: "water_load", mass_flow_kg_s: 1.0 },
|
||||
{ type: "BrineSink", name: "water_out" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "water_in:outlet", to: "water_load:inlet" },
|
||||
{ from: "water_load:outlet", to: "water_out:inlet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
subsystems: {
|
||||
Circuit: {
|
||||
components: [
|
||||
{ type: "IsentropicCompressor", name: "comp", isentropic_efficiency: 0.7 },
|
||||
{ type: "Condenser", name: "cond", ua: 700 },
|
||||
],
|
||||
edges: [{ from: "comp:outlet", to: "cond:inlet" }],
|
||||
},
|
||||
},
|
||||
instances: [{ of: "Circuit", name: "A", circuit: 0 }],
|
||||
});
|
||||
|
||||
const st = useDiagramStore.getState();
|
||||
expect(st.nodes.map((n) => n.data.name)).toEqual(
|
||||
expect.arrayContaining(["A.comp", "A.cond", "water_in", "water_load", "water_out"]),
|
||||
);
|
||||
expect(st.edges).toHaveLength(3);
|
||||
expect(st.nodes.find((n) => n.data.name === "water_load")?.data.circuit).toBe(2);
|
||||
});
|
||||
|
||||
it("lays out imported chiller cycles as a horizontal HVAC schematic", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "IsentropicCompressor", name: "comp", liquid_injection: true },
|
||||
{ type: "Condenser", name: "cond", ua: 5000 },
|
||||
{ type: "IsenthalpicExpansionValve", name: "exv", opening: 0.8 },
|
||||
{ type: "Evaporator", name: "evap", ua: 6000 },
|
||||
{ type: "BrineSource", name: "cond_water_in", t_set_c: 30 },
|
||||
{ type: "BrineSink", name: "cond_water_out" },
|
||||
{ type: "BrineSource", name: "evap_water_in", t_set_c: 12 },
|
||||
{ type: "BrineSink", name: "evap_water_out" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "comp:outlet", to: "cond:inlet" },
|
||||
{ from: "cond:outlet", to: "exv:inlet" },
|
||||
{ from: "exv:outlet", to: "evap:inlet" },
|
||||
{ from: "evap:outlet", to: "comp:inlet" },
|
||||
{ from: "cond_water_in:outlet", to: "cond:secondary_inlet" },
|
||||
{ from: "cond:secondary_outlet", to: "cond_water_out:inlet" },
|
||||
{ from: "evap_water_in:outlet", to: "evap:secondary_inlet" },
|
||||
{ from: "evap:secondary_outlet", to: "evap_water_out:inlet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
controls: [
|
||||
{
|
||||
id: "dgt_limiter",
|
||||
measure: { component: "comp", output: "temperature" },
|
||||
actuator: { component: "comp", factor: "injection", initial: 0.15, min: 0, max: 0.3 },
|
||||
target: 330,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nodes = useDiagramStore.getState().nodes;
|
||||
const byName = Object.fromEntries(nodes.map((node) => [node.data.name, node]));
|
||||
|
||||
expect(byName.evap.position.x).toBeLessThan(byName.comp.position.x);
|
||||
expect(byName.comp.position.x).toBeLessThan(byName.cond.position.x);
|
||||
expect(byName.exv.position.x).toBeGreaterThan(byName.cond.position.x);
|
||||
expect(byName.exv.position.y).toBeGreaterThan(byName.cond.position.y);
|
||||
expect(byName.dgt_limiter.position.y).toBeLessThan(byName.comp.position.y);
|
||||
|
||||
expect(byName.evap_water_out.position.y).toBeLessThan(byName.evap.position.y);
|
||||
expect(byName.evap_water_in.position.y).toBeGreaterThan(byName.evap.position.y);
|
||||
expect(byName.cond_water_out.position.y).toBeLessThan(byName.cond.position.y);
|
||||
expect(byName.cond_water_in.position.y).toBeGreaterThan(byName.cond.position.y);
|
||||
expect(useDiagramStore.getState().edges).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("uses deterministic fallback positions for partial imported configs", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "Pipe", name: "pipe_b", length_m: 2 },
|
||||
{ type: "Placeholder", name: "custom_a", n_equations: 1 },
|
||||
],
|
||||
edges: [{ from: "custom_a:outlet", to: "pipe_b:inlet" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = useDiagramStore.getState().nodes.map((node) => [node.data.name, node.position]);
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "Pipe", name: "pipe_b", length_m: 2 },
|
||||
{ type: "Placeholder", name: "custom_a", n_equations: 1 },
|
||||
],
|
||||
edges: [{ from: "custom_a:outlet", to: "pipe_b:inlet" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(useDiagramStore.getState().nodes.map((node) => [node.data.name, node.position])).toEqual(first);
|
||||
expect(useDiagramStore.getState().edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("places secondary boundaries near generic four-port heat exchangers", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "HeatExchanger", name: "hx", ua: 3000 },
|
||||
{ type: "BrineSource", name: "water_in", t_set_c: 20 },
|
||||
{ type: "BrineSink", name: "water_out" },
|
||||
{ type: "AirSource", name: "air_in", t_dry_c: 15 },
|
||||
{ type: "AirSink", name: "air_out" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "water_in:outlet", to: "hx:hot_inlet" },
|
||||
{ from: "hx:hot_outlet", to: "water_out:inlet" },
|
||||
{ from: "air_in:outlet", to: "hx:cold_inlet" },
|
||||
{ from: "hx:cold_outlet", to: "air_out:inlet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const byName = Object.fromEntries(useDiagramStore.getState().nodes.map((node) => [node.data.name, node]));
|
||||
expect(Math.abs(byName.water_in.position.x - byName.hx.position.x)).toBeLessThan(220);
|
||||
expect(Math.abs(byName.air_in.position.x - byName.hx.position.x)).toBeLessThan(220);
|
||||
expect(byName.water_out.position.y).toBeLessThan(byName.hx.position.y);
|
||||
expect(byName.air_out.position.y).toBeLessThan(byName.hx.position.y);
|
||||
expect(byName.water_in.position.y).toBeGreaterThan(byName.hx.position.y);
|
||||
expect(byName.air_in.position.y).toBeGreaterThan(byName.hx.position.y);
|
||||
});
|
||||
|
||||
it("separates multiple imported refrigeration circuits horizontally", () => {
|
||||
useDiagramStore.getState().loadFromConfig({
|
||||
fluid: "R134a",
|
||||
circuits: [
|
||||
{
|
||||
id: 0,
|
||||
components: [
|
||||
{ type: "IsentropicCompressor", name: "comp_a" },
|
||||
{ type: "Condenser", name: "cond_a" },
|
||||
],
|
||||
edges: [{ from: "comp_a:outlet", to: "cond_a:inlet" }],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
components: [
|
||||
{ type: "IsentropicCompressor", name: "comp_b" },
|
||||
{ type: "Condenser", name: "cond_b" },
|
||||
],
|
||||
edges: [{ from: "comp_b:outlet", to: "cond_b:inlet" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const byName = Object.fromEntries(useDiagramStore.getState().nodes.map((node) => [node.data.name, node]));
|
||||
expect(byName.comp_b.position.x - byName.comp_a.position.x).toBeGreaterThan(900);
|
||||
expect(byName.cond_b.position.x - byName.cond_a.position.x).toBeGreaterThan(900);
|
||||
});
|
||||
|
||||
it("clear() empties the sheet", () => {
|
||||
useDiagramStore.getState().loadFromConfig(config);
|
||||
useDiagramStore.getState().clear();
|
||||
const st = useDiagramStore.getState();
|
||||
expect(st.nodes).toHaveLength(0);
|
||||
expect(st.edges).toHaveLength(0);
|
||||
expect(st.selectedNodeId).toBeNull();
|
||||
});
|
||||
});
|
||||
712
apps/web/src/store/diagramStore.ts
Normal file
712
apps/web/src/store/diagramStore.ts
Normal file
@@ -0,0 +1,712 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Edge, Node, OnNodesChange, OnEdgesChange, OnConnect } from "@xyflow/react";
|
||||
import { applyNodeChanges, applyEdgeChanges, addEdge } from "@xyflow/react";
|
||||
import { hydrateBoundaryFixFlags } from "@/lib/boundaryFix";
|
||||
import { defaultParams } from "@/lib/componentMeta";
|
||||
import { CONTROL_NODE_TYPE, canonicalizeParams } from "@/lib/configBuilder";
|
||||
import type { ControlConfig } from "@/lib/configBuilder";
|
||||
import type { SimulationResult } from "@/lib/api";
|
||||
import {
|
||||
circuitFromEdge,
|
||||
planEdgeInsert,
|
||||
validatePipeOnEdge,
|
||||
} from "@/lib/edgeInsert";
|
||||
import { nodeSize } from "@/lib/componentMeta";
|
||||
import { normaliseRotation as normaliseRotationDeg } from "@/lib/orientation";
|
||||
|
||||
export interface EntropykNodeData {
|
||||
type: string;
|
||||
name: string;
|
||||
circuit: number;
|
||||
/** Icon rotation in degrees (0/90/180/270), Dymola-style. */
|
||||
rotation: number;
|
||||
/** Mirror about vertical axis (Modelica flip horizontal). */
|
||||
flipH: boolean;
|
||||
/** Mirror about horizontal axis (Modelica flip vertical). */
|
||||
flipV: boolean;
|
||||
params: Record<string, number | string | boolean>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type EntropykNode = Node<EntropykNodeData>;
|
||||
type PrimitiveParam = number | string | boolean;
|
||||
type ImportedComponent = Record<string, unknown> & { type: string; name: string };
|
||||
type ImportedCircuit = {
|
||||
id: number;
|
||||
components: ImportedComponent[];
|
||||
edges?: Array<{ from: string; to: string }>;
|
||||
};
|
||||
type ImportedSubsystem = {
|
||||
params?: Record<string, PrimitiveParam>;
|
||||
components: ImportedComponent[];
|
||||
edges?: Array<{ from: string; to: string }>;
|
||||
ports?: Record<string, string>;
|
||||
};
|
||||
type ImportedInstance = {
|
||||
of: string;
|
||||
name: string;
|
||||
circuit?: number;
|
||||
params?: Record<string, PrimitiveParam>;
|
||||
};
|
||||
type ImportedScenario = {
|
||||
fluid?: string;
|
||||
fluid_backend?: string;
|
||||
controls?: ControlConfig[];
|
||||
solver?: {
|
||||
strategy?: string;
|
||||
max_iterations?: number;
|
||||
tolerance?: number;
|
||||
};
|
||||
circuits?: ImportedCircuit[];
|
||||
subsystems?: Record<string, ImportedSubsystem>;
|
||||
instances?: ImportedInstance[];
|
||||
connections?: Array<{ from: string; to: string }>;
|
||||
};
|
||||
|
||||
/** Diagram grid pitch in px (Dymola snaps icons to a fixed grid). */
|
||||
export const GRID_SIZE = 12;
|
||||
|
||||
/** Snap a single coordinate to the diagram grid. */
|
||||
export function snapToGridValue(value: number, grid: number = GRID_SIZE): number {
|
||||
return Math.round(value / grid) * grid;
|
||||
}
|
||||
|
||||
/** Snap an {x, y} position to the diagram grid. */
|
||||
export function snapPosition(
|
||||
position: { x: number; y: number },
|
||||
grid: number = GRID_SIZE,
|
||||
): { x: number; y: number } {
|
||||
return { x: snapToGridValue(position.x, grid), y: snapToGridValue(position.y, grid) };
|
||||
}
|
||||
|
||||
/** Normalise any angle to one of 0/90/180/270. */
|
||||
export function normaliseRotation(deg: number): number {
|
||||
return normaliseRotationDeg(deg);
|
||||
}
|
||||
|
||||
interface DiagramState {
|
||||
nodes: EntropykNode[];
|
||||
edges: Edge[];
|
||||
selectedNodeId: string | null;
|
||||
fluid: string;
|
||||
fluidBackend: string;
|
||||
solverStrategy: string;
|
||||
maxIterations: number;
|
||||
tolerance: number;
|
||||
controls: ControlConfig[];
|
||||
snapToGrid: boolean;
|
||||
result: SimulationResult | null;
|
||||
lastConfig: unknown | null;
|
||||
simulating: boolean;
|
||||
simError: string | null;
|
||||
|
||||
onNodesChange: OnNodesChange;
|
||||
onEdgesChange: OnEdgesChange;
|
||||
onConnect: OnConnect;
|
||||
addComponent: (type: string, position: { x: number; y: number }) => void;
|
||||
/** Insert a 2-port component into an existing edge (A→B → A→comp→B). */
|
||||
insertOnEdge: (
|
||||
type: string,
|
||||
position: { x: number; y: number },
|
||||
edgeId: string,
|
||||
) => { ok: boolean; reason?: string };
|
||||
updateNodeParams: (id: string, params: Record<string, number | string | boolean>) => void;
|
||||
/** Apply param patches to several nodes in one atomic set() (Modelica Fixed pairing). */
|
||||
updateNodesParams: (
|
||||
patches: Map<string, Record<string, number | string | boolean>>,
|
||||
) => void;
|
||||
updateNodeName: (id: string, name: string) => void;
|
||||
updateNodeCircuit: (id: string, circuit: number) => void;
|
||||
rotateNode: (id: string, dir?: 1 | -1) => void;
|
||||
/** Modelica flip about vertical axis. */
|
||||
flipNodeH: (id: string) => void;
|
||||
/** Modelica flip about horizontal axis. */
|
||||
flipNodeV: (id: string) => void;
|
||||
setSelected: (id: string | null) => void;
|
||||
removeNode: (id: string) => void;
|
||||
setFluid: (fluid: string) => void;
|
||||
setFluidBackend: (backend: string) => void;
|
||||
setSolverStrategy: (strategy: string) => void;
|
||||
setMaxIterations: (maxIterations: number) => void;
|
||||
setTolerance: (tolerance: number) => void;
|
||||
setControls: (controls: ControlConfig[]) => void;
|
||||
toggleSnapToGrid: () => void;
|
||||
setLastConfig: (config: unknown | null) => void;
|
||||
setResult: (result: SimulationResult | null, error?: string | null) => void;
|
||||
setSimulating: (v: boolean) => void;
|
||||
loadFromConfig: (config: unknown) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
let nameCounter: Record<string, number> = {};
|
||||
|
||||
function uniqueName(type: string): string {
|
||||
nameCounter[type] = (nameCounter[type] ?? 0) + 1;
|
||||
const base = type.toLowerCase().replace(/[^a-z0-9]/g, "_").slice(0, 8);
|
||||
return `${base}_${nameCounter[type]}`;
|
||||
}
|
||||
|
||||
function substituteTemplateValue(value: unknown, params: Record<string, PrimitiveParam>): unknown {
|
||||
if (typeof value === "string" && value.startsWith("$")) {
|
||||
return params[value.slice(1)] ?? value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function substituteComponent(
|
||||
component: ImportedComponent,
|
||||
params: Record<string, PrimitiveParam>,
|
||||
prefix?: string,
|
||||
): ImportedComponent {
|
||||
const next: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(component)) {
|
||||
next[key] = substituteTemplateValue(value, params);
|
||||
}
|
||||
next.name = prefix ? `${prefix}.${component.name}` : component.name;
|
||||
return next as ImportedComponent;
|
||||
}
|
||||
|
||||
function prefixEdgeRef(ref: string, prefix: string): string {
|
||||
const [component, port] = ref.split(":");
|
||||
return port ? `${prefix}.${component}:${port}` : `${prefix}.${component}`;
|
||||
}
|
||||
|
||||
function resolveInstancePortRef(
|
||||
ref: string,
|
||||
instances: Map<string, ImportedInstance>,
|
||||
subsystems: Record<string, ImportedSubsystem>,
|
||||
): { circuit: number; ref: string } | null {
|
||||
const [instanceName, portName] = ref.split(".");
|
||||
if (!instanceName || !portName) return null;
|
||||
|
||||
const instance = instances.get(instanceName);
|
||||
if (!instance) return null;
|
||||
|
||||
const subsystem = subsystems[instance.of];
|
||||
const mapped = subsystem?.ports?.[portName];
|
||||
if (!mapped) return null;
|
||||
|
||||
return {
|
||||
circuit: instance.circuit ?? 0,
|
||||
ref: prefixEdgeRef(mapped, instance.name),
|
||||
};
|
||||
}
|
||||
|
||||
function flattenImportedScenario(cfg: ImportedScenario): ImportedScenario {
|
||||
if (!cfg.subsystems || !cfg.instances?.length) return cfg;
|
||||
|
||||
const circuits = new Map<number, ImportedCircuit>(
|
||||
(cfg.circuits ?? []).map((circuit) => [
|
||||
circuit.id,
|
||||
{
|
||||
...circuit,
|
||||
components: [...circuit.components],
|
||||
edges: [...(circuit.edges ?? [])],
|
||||
},
|
||||
]),
|
||||
);
|
||||
const instancesByName = new Map(cfg.instances.map((instance) => [instance.name, instance]));
|
||||
|
||||
for (const instance of cfg.instances) {
|
||||
const subsystem = cfg.subsystems[instance.of];
|
||||
if (!subsystem) continue;
|
||||
|
||||
const circuitId = instance.circuit ?? 0;
|
||||
const circuit = circuits.get(circuitId) ?? { id: circuitId, components: [], edges: [] };
|
||||
const params = { ...(subsystem.params ?? {}), ...(instance.params ?? {}) };
|
||||
|
||||
circuit.components.push(
|
||||
...subsystem.components.map((component) => substituteComponent(component, params, instance.name)),
|
||||
);
|
||||
circuit.edges?.push(
|
||||
...(subsystem.edges ?? []).map((edge) => ({
|
||||
from: prefixEdgeRef(edge.from, instance.name),
|
||||
to: prefixEdgeRef(edge.to, instance.name),
|
||||
})),
|
||||
);
|
||||
|
||||
circuits.set(circuitId, circuit);
|
||||
}
|
||||
|
||||
for (const connection of cfg.connections ?? []) {
|
||||
const from = resolveInstancePortRef(connection.from, instancesByName, cfg.subsystems);
|
||||
const to = resolveInstancePortRef(connection.to, instancesByName, cfg.subsystems);
|
||||
if (!from || !to || from.circuit !== to.circuit) continue;
|
||||
|
||||
const circuit = circuits.get(from.circuit) ?? { id: from.circuit, components: [], edges: [] };
|
||||
circuit.edges?.push({ from: from.ref, to: to.ref });
|
||||
circuits.set(from.circuit, circuit);
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
circuits: Array.from(circuits.values()).sort((a, b) => a.id - b.id),
|
||||
};
|
||||
}
|
||||
|
||||
function controlParams(control: ControlConfig): Record<string, PrimitiveParam> {
|
||||
return {
|
||||
measure_component: control.measure.component,
|
||||
measure_output: control.measure.output,
|
||||
actuator_component: control.actuator.component,
|
||||
actuator_factor: control.actuator.factor,
|
||||
initial: control.actuator.initial ?? 0.15,
|
||||
min: control.actuator.min,
|
||||
max: control.actuator.max,
|
||||
target: control.target,
|
||||
gain: control.gain ?? -0.5,
|
||||
band: control.band ?? 5.0,
|
||||
...(control.smooth_eps !== undefined ? { smooth_eps: control.smooth_eps } : {}),
|
||||
...(control.alpha !== undefined ? { alpha: control.alpha } : {}),
|
||||
objectives_json: JSON.stringify(control.objectives ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function isCompressorNode(node: EntropykNode): boolean {
|
||||
return node.data.type.includes("Compressor");
|
||||
}
|
||||
|
||||
function isCondenserNode(node: EntropykNode): boolean {
|
||||
const text = `${node.data.type} ${node.data.name}`.toLowerCase();
|
||||
return text.includes("condenser") || /\bcond\b/.test(text);
|
||||
}
|
||||
|
||||
function isEvaporatorNode(node: EntropykNode): boolean {
|
||||
const text = `${node.data.type} ${node.data.name}`.toLowerCase();
|
||||
return text.includes("evaporator") || /\bevap\b/.test(text);
|
||||
}
|
||||
|
||||
function isExpansionNode(node: EntropykNode): boolean {
|
||||
const text = `${node.data.type} ${node.data.name}`.toLowerCase();
|
||||
return node.data.type.includes("Valve") || text.includes("exv") || text.includes("expansion");
|
||||
}
|
||||
|
||||
function isGenericHeatExchangerNode(node: EntropykNode): boolean {
|
||||
return node.data.type === "HeatExchanger";
|
||||
}
|
||||
|
||||
function isControllerNode(node: EntropykNode): boolean {
|
||||
return node.data.type === CONTROL_NODE_TYPE;
|
||||
}
|
||||
|
||||
function isSecondaryBoundaryNode(node: EntropykNode): boolean {
|
||||
return node.data.type.startsWith("Brine") || node.data.type.startsWith("Air");
|
||||
}
|
||||
|
||||
function connectedPortOn(nodeId: string, edge: Edge): string {
|
||||
if (edge.source === nodeId) return String(edge.sourceHandle ?? "");
|
||||
if (edge.target === nodeId) return String(edge.targetHandle ?? "");
|
||||
return "";
|
||||
}
|
||||
|
||||
function oppositeNodeId(nodeId: string, edge: Edge): string | null {
|
||||
if (edge.source === nodeId) return edge.target;
|
||||
if (edge.target === nodeId) return edge.source;
|
||||
return null;
|
||||
}
|
||||
|
||||
function sortedByName(nodes: EntropykNode[]): EntropykNode[] {
|
||||
return [...nodes].sort((a, b) => a.data.name.localeCompare(b.data.name));
|
||||
}
|
||||
|
||||
function firstByFamily(nodes: EntropykNode[], predicate: (node: EntropykNode) => boolean): EntropykNode | undefined {
|
||||
return sortedByName(nodes.filter(predicate))[0];
|
||||
}
|
||||
|
||||
function layoutImportedNodes(nodes: EntropykNode[], edges: Edge[]): EntropykNode[] {
|
||||
if (nodes.length === 0) return nodes;
|
||||
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const byCircuit = new Map<number, EntropykNode[]>();
|
||||
for (const node of nodes) {
|
||||
const circuit = node.data.circuit ?? 0;
|
||||
byCircuit.set(circuit, [...(byCircuit.get(circuit) ?? []), node]);
|
||||
}
|
||||
|
||||
const secondaryCounts = new Map<string, number>();
|
||||
const fallbackCounts = new Map<number, number>();
|
||||
|
||||
for (const [layoutIndex, [circuitId, circuitNodes]] of [...byCircuit.entries()].sort(([a], [b]) => a - b).entries()) {
|
||||
const xOffset = layoutIndex * 1100;
|
||||
const yOffset = layoutIndex * 40;
|
||||
const evaporator = firstByFamily(circuitNodes, isEvaporatorNode);
|
||||
const compressor = firstByFamily(circuitNodes, isCompressorNode);
|
||||
const condenser = firstByFamily(circuitNodes, isCondenserNode);
|
||||
const expansion = firstByFamily(circuitNodes, isExpansionNode);
|
||||
const genericHeatExchangers = sortedByName(circuitNodes.filter(isGenericHeatExchangerNode));
|
||||
|
||||
if (evaporator) positions.set(evaporator.id, { x: 420 + xOffset, y: 300 + yOffset });
|
||||
if (compressor) positions.set(compressor.id, { x: 720 + xOffset, y: 270 + yOffset });
|
||||
if (condenser) positions.set(condenser.id, { x: 1040 + xOffset, y: 300 + yOffset });
|
||||
if (expansion) positions.set(expansion.id, { x: 1260 + xOffset, y: 450 + yOffset });
|
||||
genericHeatExchangers.forEach((node, index) => {
|
||||
if (!positions.has(node.id)) {
|
||||
positions.set(node.id, { x: 420 + xOffset + index * 260, y: 300 + yOffset });
|
||||
}
|
||||
});
|
||||
|
||||
for (const controller of sortedByName(circuitNodes.filter(isControllerNode))) {
|
||||
const measuredName = String(controller.data.params.measure_component ?? controller.data.params.actuator_component ?? "");
|
||||
const anchor =
|
||||
circuitNodes.find((node) => node.data.name === measuredName) ??
|
||||
compressor ??
|
||||
evaporator ??
|
||||
condenser;
|
||||
const anchorPosition = anchor ? positions.get(anchor.id) ?? anchor.position : { x: 600 + xOffset, y: 220 + yOffset };
|
||||
positions.set(controller.id, { x: anchorPosition.x - 120, y: anchorPosition.y - 150 });
|
||||
}
|
||||
|
||||
for (const boundary of sortedByName(circuitNodes.filter(isSecondaryBoundaryNode))) {
|
||||
const link = edges
|
||||
.filter((edge) => edge.source === boundary.id || edge.target === boundary.id)
|
||||
.map((edge) => {
|
||||
const otherId = oppositeNodeId(boundary.id, edge);
|
||||
const other = otherId ? nodeById.get(otherId) : undefined;
|
||||
return other ? { edge, other } : null;
|
||||
})
|
||||
.find((entry): entry is { edge: Edge; other: EntropykNode } => !!entry);
|
||||
if (!link) continue;
|
||||
|
||||
const anchorPosition = positions.get(link.other.id);
|
||||
if (!anchorPosition) continue;
|
||||
|
||||
const exchangerPort = connectedPortOn(link.other.id, link.edge);
|
||||
const key = `${link.other.id}:${exchangerPort || "secondary"}`;
|
||||
const count = secondaryCounts.get(key) ?? 0;
|
||||
secondaryCounts.set(key, count + 1);
|
||||
|
||||
const isOutlet = exchangerPort.includes("outlet");
|
||||
const vertical = isOutlet ? -150 : 150;
|
||||
const horizontal =
|
||||
link.other === condenser
|
||||
? isOutlet ? -40 : -70
|
||||
: isOutlet ? 120 : -40;
|
||||
positions.set(boundary.id, {
|
||||
x: anchorPosition.x + horizontal + count * 84,
|
||||
y: anchorPosition.y + vertical,
|
||||
});
|
||||
}
|
||||
|
||||
for (const node of sortedByName(circuitNodes)) {
|
||||
if (positions.has(node.id)) continue;
|
||||
const count = fallbackCounts.get(circuitId) ?? 0;
|
||||
fallbackCounts.set(circuitId, count + 1);
|
||||
positions.set(node.id, {
|
||||
x: 120 + count * 170 + xOffset,
|
||||
y: 120 + yOffset + (count % 2) * 140,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
position: snapPosition(positions.get(node.id) ?? node.position),
|
||||
}));
|
||||
}
|
||||
|
||||
export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodeId: null,
|
||||
fluid: "R410A",
|
||||
fluidBackend: "CoolProp",
|
||||
solverStrategy: "newton",
|
||||
maxIterations: 300,
|
||||
tolerance: 1e-6,
|
||||
controls: [],
|
||||
snapToGrid: true,
|
||||
result: null,
|
||||
lastConfig: null,
|
||||
simulating: false,
|
||||
simError: null,
|
||||
|
||||
onNodesChange: (changes) => {
|
||||
const removed = changes.filter((c) => c.type === "remove").map((c) => c.id);
|
||||
const sel = get().selectedNodeId;
|
||||
set({
|
||||
nodes: applyNodeChanges(changes, get().nodes) as EntropykNode[],
|
||||
selectedNodeId: sel && removed.includes(sel) ? null : sel,
|
||||
});
|
||||
},
|
||||
|
||||
onEdgesChange: (changes) =>
|
||||
set({ edges: applyEdgeChanges(changes, get().edges) }),
|
||||
|
||||
onConnect: (connection) =>
|
||||
set({
|
||||
edges: addEdge(
|
||||
{ ...connection, animated: false, type: "smoothstep" },
|
||||
get().edges,
|
||||
),
|
||||
}),
|
||||
|
||||
addComponent: (type, position) => {
|
||||
const id = crypto.randomUUID();
|
||||
const pos = get().snapToGrid ? snapPosition(position) : position;
|
||||
const node: EntropykNode = {
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: pos,
|
||||
data: {
|
||||
type,
|
||||
name: uniqueName(type),
|
||||
circuit: 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params: defaultParams(type),
|
||||
},
|
||||
};
|
||||
set({ nodes: [...get().nodes, node], selectedNodeId: id });
|
||||
},
|
||||
|
||||
insertOnEdge: (type, position, edgeId) => {
|
||||
const { nodes, edges, snapToGrid } = get();
|
||||
const edge = edges.find((e) => e.id === edgeId);
|
||||
if (!edge) return { ok: false, reason: "Ligne introuvable." };
|
||||
|
||||
const params = defaultParams(type);
|
||||
const check = validatePipeOnEdge(type, params, edge, nodes as EntropykNode[]);
|
||||
if (!check.ok) return check;
|
||||
|
||||
const src = nodes.find((n) => n.id === edge.source);
|
||||
const tgt = nodes.find((n) => n.id === edge.target);
|
||||
if (!src || !tgt) return { ok: false, reason: "Extrémités de ligne manquantes." };
|
||||
|
||||
const srcSize = nodeSize(src.data.type);
|
||||
const tgtSize = nodeSize(tgt.data.type);
|
||||
const sourceCenter = {
|
||||
x: src.position.x + srcSize.w / 2,
|
||||
y: src.position.y + srcSize.h / 2,
|
||||
};
|
||||
const targetCenter = {
|
||||
x: tgt.position.x + tgtSize.w / 2,
|
||||
y: tgt.position.y + tgtSize.h / 2,
|
||||
};
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const name = uniqueName(type);
|
||||
const pos = snapToGrid ? snapPosition(position) : position;
|
||||
const plan = planEdgeInsert({
|
||||
type,
|
||||
id,
|
||||
name,
|
||||
position: pos,
|
||||
params,
|
||||
edge,
|
||||
circuit: circuitFromEdge(edge, nodes as EntropykNode[]),
|
||||
sourceCenter,
|
||||
targetCenter,
|
||||
});
|
||||
|
||||
// Snap final position after orientation centering
|
||||
if (snapToGrid) {
|
||||
plan.node.position = snapPosition(plan.node.position);
|
||||
}
|
||||
|
||||
set({
|
||||
nodes: [...nodes, plan.node as EntropykNode],
|
||||
edges: [
|
||||
...edges.filter((e) => e.id !== plan.edgeIdToRemove),
|
||||
...plan.edgesToAdd,
|
||||
],
|
||||
selectedNodeId: id,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
updateNodeParams: (id, params) =>
|
||||
set({
|
||||
nodes: get().nodes.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...n.data, params: { ...n.data.params, ...params } } } : n,
|
||||
),
|
||||
}),
|
||||
|
||||
updateNodesParams: (patches) =>
|
||||
set({
|
||||
nodes: get().nodes.map((n) => {
|
||||
const patch = patches.get(n.id);
|
||||
if (!patch) return n;
|
||||
return { ...n, data: { ...n.data, params: { ...n.data.params, ...patch } } };
|
||||
}),
|
||||
}),
|
||||
|
||||
updateNodeName: (id, name) =>
|
||||
set({
|
||||
nodes: get().nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, name } } : n)),
|
||||
}),
|
||||
|
||||
updateNodeCircuit: (id, circuit) =>
|
||||
set({
|
||||
nodes: get().nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, circuit } } : n)),
|
||||
}),
|
||||
|
||||
rotateNode: (id, dir = 1) =>
|
||||
set({
|
||||
nodes: get().nodes.map((n) =>
|
||||
n.id === id
|
||||
? {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
rotation: normaliseRotation((n.data.rotation ?? 0) + dir * 90),
|
||||
},
|
||||
}
|
||||
: n,
|
||||
),
|
||||
}),
|
||||
|
||||
flipNodeH: (id) =>
|
||||
set({
|
||||
nodes: get().nodes.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...n.data, flipH: !n.data.flipH } } : n,
|
||||
),
|
||||
}),
|
||||
|
||||
flipNodeV: (id) =>
|
||||
set({
|
||||
nodes: get().nodes.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...n.data, flipV: !n.data.flipV } } : n,
|
||||
),
|
||||
}),
|
||||
|
||||
setSelected: (id) => {
|
||||
if (get().selectedNodeId === id) return;
|
||||
set({ selectedNodeId: id });
|
||||
},
|
||||
|
||||
removeNode: (id) =>
|
||||
set({
|
||||
nodes: get().nodes.filter((n) => n.id !== id),
|
||||
edges: get().edges.filter((e) => e.source !== id && e.target !== id),
|
||||
selectedNodeId: get().selectedNodeId === id ? null : get().selectedNodeId,
|
||||
}),
|
||||
|
||||
setFluid: (fluid) => set({ fluid }),
|
||||
setFluidBackend: (backend) => set({ fluidBackend: backend }),
|
||||
setSolverStrategy: (strategy) => set({ solverStrategy: strategy }),
|
||||
setMaxIterations: (maxIterations) => set({ maxIterations }),
|
||||
setTolerance: (tolerance) => set({ tolerance }),
|
||||
setControls: (controls) => set({ controls }),
|
||||
toggleSnapToGrid: () => set({ snapToGrid: !get().snapToGrid }),
|
||||
|
||||
setLastConfig: (config) => set({ lastConfig: config }),
|
||||
setResult: (result, error = null) => set({ result, simError: error }),
|
||||
setSimulating: (v) => set({ simulating: v }),
|
||||
|
||||
loadFromConfig: (config) => {
|
||||
const cfg = flattenImportedScenario(config as ImportedScenario);
|
||||
if (!cfg?.circuits) return;
|
||||
|
||||
const nodes: EntropykNode[] = [];
|
||||
let x = 100;
|
||||
const nameToId = new Map<string, string>();
|
||||
|
||||
for (const circuit of cfg.circuits) {
|
||||
let y = 100;
|
||||
for (const comp of circuit.components) {
|
||||
const id = crypto.randomUUID();
|
||||
nameToId.set(`${circuit.id}:${comp.name}`, id);
|
||||
const { type, name, ...rest } = comp;
|
||||
// Keep only primitive params.
|
||||
let params: Record<string, PrimitiveParam> = {};
|
||||
for (const [k, v] of Object.entries(rest)) {
|
||||
if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") {
|
||||
params[k] = v;
|
||||
}
|
||||
}
|
||||
params = canonicalizeParams(type as string, params);
|
||||
params = hydrateBoundaryFixFlags(type as string, params);
|
||||
// Merge meta defaults without clobbering imported Fixed flags / setpoints.
|
||||
const defaults = defaultParams(type as string);
|
||||
params = { ...defaults, ...params };
|
||||
nodes.push({
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: { x, y },
|
||||
data: {
|
||||
type: type as string,
|
||||
name: name as string,
|
||||
circuit: circuit.id,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params,
|
||||
},
|
||||
});
|
||||
y += 120;
|
||||
}
|
||||
x += 280;
|
||||
}
|
||||
|
||||
for (const [index, control] of (cfg.controls ?? []).entries()) {
|
||||
const measuredNode = nodes.find((node) => node.data.name === control.measure.component);
|
||||
const id = crypto.randomUUID();
|
||||
nodes.push({
|
||||
id,
|
||||
type: "entropykNode",
|
||||
position: measuredNode
|
||||
? { x: measuredNode.position.x + 140, y: Math.max(40, measuredNode.position.y - 72) }
|
||||
: { x: 100 + index * 150, y: 40 },
|
||||
data: {
|
||||
type: CONTROL_NODE_TYPE,
|
||||
name: control.id,
|
||||
circuit: measuredNode?.data.circuit ?? 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params: controlParams(control),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const edges: Edge[] = [];
|
||||
for (const circuit of cfg.circuits) {
|
||||
circuit.edges?.forEach((e, i) => {
|
||||
const [fromName, fromPort] = e.from.split(":");
|
||||
const [toName, toPort] = e.to.split(":");
|
||||
const sId = nameToId.get(`${circuit.id}:${fromName}`);
|
||||
const tId = nameToId.get(`${circuit.id}:${toName}`);
|
||||
if (sId && tId) {
|
||||
edges.push({
|
||||
id: `e${circuit.id}-${i}`,
|
||||
source: sId,
|
||||
target: tId,
|
||||
sourceHandle: fromPort,
|
||||
targetHandle: toPort,
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
set({
|
||||
nodes: layoutImportedNodes(nodes, edges),
|
||||
edges,
|
||||
fluid: cfg.fluid || "R410A",
|
||||
fluidBackend: cfg.fluid_backend || "CoolProp",
|
||||
solverStrategy: cfg.solver?.strategy || "newton",
|
||||
maxIterations: cfg.solver?.max_iterations ?? 300,
|
||||
tolerance: cfg.solver?.tolerance ?? 1e-6,
|
||||
controls: cfg.controls ?? [],
|
||||
result: null,
|
||||
lastConfig: cfg,
|
||||
simError: null,
|
||||
selectedNodeId: null,
|
||||
});
|
||||
},
|
||||
|
||||
clear: () =>
|
||||
set({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
controls: [],
|
||||
result: null,
|
||||
lastConfig: null,
|
||||
simError: null,
|
||||
selectedNodeId: null,
|
||||
}),
|
||||
}));
|
||||
18
apps/web/tailwind.config.ts
Normal file
18
apps/web/tailwind.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
21
apps/web/tsconfig.json
Normal file
21
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
19
apps/web/vitest.config.ts
Normal file
19
apps/web/vitest.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||
css: false,
|
||||
},
|
||||
});
|
||||
17
apps/web/vitest.setup.ts
Normal file
17
apps/web/vitest.setup.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
// crypto.randomUUID is used by the diagram store; jsdom may not provide it.
|
||||
const existingCrypto = (globalThis as { crypto?: Partial<Crypto> }).crypto;
|
||||
if (typeof existingCrypto?.randomUUID !== "function") {
|
||||
let counter = 0;
|
||||
const randomUUID = (): `${string}-${string}-${string}-${string}-${string}` =>
|
||||
`00000000-0000-4000-8000-${String(counter++).padStart(12, "0")}` as const;
|
||||
const cryptoStub = { ...(existingCrypto ?? {}), randomUUID } as Crypto;
|
||||
Object.defineProperty(globalThis, "crypto", { value: cryptoStub, configurable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -7,6 +7,10 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "entropyk_cli"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "entropyk-cli"
|
||||
path = "src/main.rs"
|
||||
@@ -16,11 +20,12 @@ entropyk = { path = "../entropyk" }
|
||||
entropyk-core = { path = "../core" }
|
||||
entropyk-components = { path = "../components" }
|
||||
entropyk-solver = { path = "../solver" }
|
||||
entropyk-fluids = { path = "../fluids" }
|
||||
entropyk-fluids = { path = "../fluids", features = ["coolprop"] }
|
||||
|
||||
clap = { version = "4.4", features = ["derive", "color"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "0.8"
|
||||
anyhow = "1.0"
|
||||
thiserror = { workspace = true }
|
||||
tracing = "0.1"
|
||||
|
||||
19
crates/cli/examples/_ui_repro_flooded_port.json
Normal file
19
crates/cli/examples/_ui_repro_flooded_port.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"schema_version": "2",
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
"circuits": [{
|
||||
"id": 0,
|
||||
"name": "Circuit 0",
|
||||
"components": [
|
||||
{"type": "FloodedEvaporator", "name": "evap", "ua": 8000.0, "secondary_fluid": "Water", "quality_control": false},
|
||||
{"type": "BrineSource", "name": "src", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.5},
|
||||
{"type": "BrineSink", "name": "sink", "fluid": "Water", "p_back_bar": 2.0}
|
||||
],
|
||||
"edges": [
|
||||
{"from": "src:outlet", "to": "evap:secondary_inlet"},
|
||||
{"from": "evap:secondary_outlet", "to": "sink:inlet"}
|
||||
]
|
||||
}],
|
||||
"solver": {"strategy": "newton", "max_iterations": 10, "tolerance": 1e-4}
|
||||
}
|
||||
@@ -1,94 +1,32 @@
|
||||
{
|
||||
"name": "BPHX Evaporator + Condenser — standalone runnable examples",
|
||||
"fluid": "R410A",
|
||||
"name": "BPHX Evaporator and Condenser Bounded Test",
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Evaporator circuit (R410A / Water)",
|
||||
"components": [
|
||||
{
|
||||
"type": "RefrigerantSource",
|
||||
"name": "ref_src_evap",
|
||||
"fluid": "R410A",
|
||||
"p_set_bar": 4.0,
|
||||
"quality": 0.2
|
||||
},
|
||||
{
|
||||
"type": "BphxEvaporator",
|
||||
"name": "evap",
|
||||
"refrigerant": "R410A",
|
||||
"secondary_fluid": "Water",
|
||||
"mode": "dx",
|
||||
"target_superheat_k": 5.0,
|
||||
"dh_m": 0.003,
|
||||
"area_m2": 0.5,
|
||||
"n_plates": 20,
|
||||
"hot_fluid": "Water",
|
||||
"hot_t_inlet_c": 12.0,
|
||||
"hot_pressure_bar": 2.0,
|
||||
"hot_mass_flow_kg_s": 0.5,
|
||||
"cold_fluid": "R410A",
|
||||
"cold_t_inlet_c": 2.0,
|
||||
"cold_pressure_bar": 4.0,
|
||||
"cold_mass_flow_kg_s": 0.1
|
||||
},
|
||||
{
|
||||
"type": "RefrigerantSink",
|
||||
"name": "ref_snk_evap",
|
||||
"fluid": "R410A",
|
||||
"p_back_bar": 4.0
|
||||
}
|
||||
{ "type": "RefrigerantSource", "name": "src", "fluid": "R134a", "p_set_bar": 5.0, "quality": 0.3 },
|
||||
{ "type": "BphxEvaporator", "name": "evap", "ua": 2000.0, "refrigerant": "R134a", "secondary_fluid": "Water", "secondary_inlet_temp_c": 12.0, "secondary_mass_flow_kg_s": 0.5, "secondary_cp_j_per_kgk": 4186.0 },
|
||||
{ "type": "RefrigerantSink", "name": "sink", "fluid": "R134a", "p_back_bar": 5.0 }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "ref_src_evap:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "ref_snk_evap:inlet" }
|
||||
{ "from": "src:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "sink:inlet" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Condenser circuit (R410A / Water)",
|
||||
"components": [
|
||||
{
|
||||
"type": "RefrigerantSource",
|
||||
"name": "ref_src_cond",
|
||||
"fluid": "R410A",
|
||||
"p_set_bar": 18.0,
|
||||
"quality": 1.0
|
||||
},
|
||||
{
|
||||
"type": "BphxCondenser",
|
||||
"name": "cond",
|
||||
"refrigerant": "R410A",
|
||||
"secondary_fluid": "Water",
|
||||
"target_subcooling_k": 3.0,
|
||||
"dh_m": 0.003,
|
||||
"area_m2": 0.5,
|
||||
"n_plates": 20,
|
||||
"hot_fluid": "R410A",
|
||||
"hot_t_inlet_c": 45.0,
|
||||
"hot_pressure_bar": 18.0,
|
||||
"hot_mass_flow_kg_s": 0.1,
|
||||
"cold_fluid": "Water",
|
||||
"cold_t_inlet_c": 25.0,
|
||||
"cold_pressure_bar": 2.0,
|
||||
"cold_mass_flow_kg_s": 0.5
|
||||
},
|
||||
{
|
||||
"type": "RefrigerantSink",
|
||||
"name": "ref_snk_cond",
|
||||
"fluid": "R410A",
|
||||
"p_back_bar": 18.0
|
||||
}
|
||||
{ "type": "RefrigerantSource", "name": "src2", "fluid": "R134a", "p_set_bar": 15.0, "quality": 1.0 },
|
||||
{ "type": "BphxCondenser", "name": "cond", "ua": 2000.0, "refrigerant": "R134a", "secondary_fluid": "Water", "secondary_inlet_temp_c": 30.0, "secondary_mass_flow_kg_s": 0.4, "secondary_cp_j_per_kgk": 4186.0 },
|
||||
{ "type": "RefrigerantSink", "name": "sink2", "fluid": "R134a", "p_back_bar": 15.0 }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "ref_src_cond:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "ref_snk_cond:inlet" }
|
||||
{ "from": "src2:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "sink2:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"solver": {
|
||||
"strategy": "fallback",
|
||||
"max_iterations": 50,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
"solver": { "strategy": "fallback", "max_iterations": 100, "tolerance": 1e-6 }
|
||||
}
|
||||
|
||||
30
crates/cli/examples/capillary_tube_r134a.json
Normal file
30
crates/cli/examples/capillary_tube_r134a.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Capillary smoke",
|
||||
"components": [
|
||||
{
|
||||
"type": "CapillaryTube",
|
||||
"name": "cap",
|
||||
"diameter_m": 0.0012,
|
||||
"length_m": 1.8,
|
||||
"n_segments": 24,
|
||||
"p_inlet_bar": 12.0,
|
||||
"h_inlet_kj_kg": 250.0,
|
||||
"p_outlet_bar": 3.5,
|
||||
"h_outlet_kj_kg": 250.0
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
],
|
||||
"solver": {
|
||||
"strategy": "newton",
|
||||
"max_iterations": 50,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
107
crates/cli/examples/chiller_aircooled_r134a.json
Normal file
107
crates/cli/examples/chiller_aircooled_r134a.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "Air-Cooled Chiller R134a (4-Port Modelica Style)",
|
||||
"description": "Full emergent-pressure chiller. Condenser on air (AirSource→cond→AirSink), evaporator on chilled water (BrineSource→evap→BrineSink). MassFlowSource_T: Free P + Fixed ṁ/T; sinks Fixed P. secondary_humidity_ratio MUST match AirSource psychrometrics (W at T_dry, RH, P).",
|
||||
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Refrigerant + secondary loops",
|
||||
"components": [
|
||||
{
|
||||
"type": "IsentropicCompressor",
|
||||
"name": "comp",
|
||||
"isentropic_efficiency": 0.70,
|
||||
"t_cond_k": 318.15,
|
||||
"t_evap_k": 278.15,
|
||||
"superheat_k": 5.0,
|
||||
"emergent_pressure": true,
|
||||
"displacement_m3": 6.5e-5,
|
||||
"speed_hz": 50.0,
|
||||
"volumetric_efficiency": 0.92
|
||||
},
|
||||
{
|
||||
"type": "Condenser",
|
||||
"name": "cond",
|
||||
"ua": 2500.0,
|
||||
"emergent_pressure": true,
|
||||
"subcooling_k": 5.0,
|
||||
"secondary_fluid": "Air",
|
||||
"secondary_humidity_ratio": 0.01412,
|
||||
"dp_model": "isobaric",
|
||||
"secondary_rated_pressure_drop_pa": 150,
|
||||
"secondary_rated_m_flow_kg_s": 1.2
|
||||
},
|
||||
{
|
||||
"type": "IsenthalpicExpansionValve",
|
||||
"name": "exv",
|
||||
"t_evap_k": 278.15,
|
||||
"emergent_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "Evaporator",
|
||||
"name": "evap",
|
||||
"ua": 1468.0,
|
||||
"emergent_pressure": true,
|
||||
"secondary_fluid": "Water",
|
||||
"dp_model": "isobaric",
|
||||
"secondary_rated_pressure_drop_pa": 40000,
|
||||
"secondary_rated_m_flow_kg_s": 0.4778
|
||||
},
|
||||
{
|
||||
"type": "AirSource",
|
||||
"name": "cond_air_in",
|
||||
"p_set_bar": 1.01325,
|
||||
"t_dry_c": 35.0,
|
||||
"rh": 40.0,
|
||||
"m_flow_kg_s": 1.2,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "AirSink",
|
||||
"name": "cond_air_out",
|
||||
"p_back_bar": 1.01325,
|
||||
"fix_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "evap_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 3.0,
|
||||
"t_set_c": 12.0,
|
||||
"m_flow_kg_s": 0.4778,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "evap_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 3.0,
|
||||
"fix_pressure": true
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "comp:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "comp:inlet" },
|
||||
{ "from": "cond_air_in:outlet", "to": "cond:secondary_inlet" },
|
||||
{ "from": "cond:secondary_outlet", "to": "cond_air_out:inlet" },
|
||||
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"solver": {
|
||||
"strategy": "newton",
|
||||
"max_iterations": 300,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
107
crates/cli/examples/chiller_flooded_4port_watercooled.json
Normal file
107
crates/cli/examples/chiller_flooded_4port_watercooled.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "Water-cooled chiller with FloodedEvaporator (4-port, square DoF)",
|
||||
"description": "Honest machine topology: emergent refrigerant pressures + live secondary water loops. Flooded evaporator has NO quality_control residual (compressor suction). Budget target: n_eq = n_unk (19).",
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Refrigerant + secondary loops",
|
||||
"components": [
|
||||
{
|
||||
"type": "IsentropicCompressor",
|
||||
"name": "comp",
|
||||
"isentropic_efficiency": 0.70,
|
||||
"t_cond_k": 313.15,
|
||||
"t_evap_k": 278.15,
|
||||
"superheat_k": 5.0,
|
||||
"emergent_pressure": true,
|
||||
"displacement_m3": 5.0e-5,
|
||||
"speed_hz": 50.0,
|
||||
"volumetric_efficiency": 0.92
|
||||
},
|
||||
{
|
||||
"type": "Condenser",
|
||||
"name": "cond",
|
||||
"ua": 2200.0,
|
||||
"emergent_pressure": true,
|
||||
"subcooling_k": 5.0,
|
||||
"secondary_fluid": "Water",
|
||||
"dp_model": "msh",
|
||||
"tube_length_m": 6.0,
|
||||
"tube_diameter_m": 0.0095,
|
||||
"n_parallel_tubes": 2,
|
||||
"secondary_rated_pressure_drop_pa": 30000,
|
||||
"secondary_rated_m_flow_kg_s": 0.45
|
||||
},
|
||||
{
|
||||
"type": "IsenthalpicExpansionValve",
|
||||
"name": "exv",
|
||||
"t_evap_k": 278.15,
|
||||
"emergent_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "FloodedEvaporator",
|
||||
"name": "evap",
|
||||
"ua": 9000.0,
|
||||
"refrigerant": "R134a",
|
||||
"secondary_fluid": "Water",
|
||||
"quality_control": false,
|
||||
"secondary_rated_pressure_drop_pa": 40000,
|
||||
"secondary_rated_m_flow_kg_s": 0.55
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "cond_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 2.0,
|
||||
"t_set_c": 30.0,
|
||||
"m_flow_kg_s": 0.45,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "cond_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 2.0,
|
||||
"fix_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "evap_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 3.0,
|
||||
"t_set_c": 12.0,
|
||||
"m_flow_kg_s": 0.55,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "evap_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 3.0,
|
||||
"fix_pressure": true
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "comp:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "comp:inlet" },
|
||||
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
|
||||
{ "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
|
||||
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"solver": {
|
||||
"strategy": "newton",
|
||||
"max_iterations": 300,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user