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:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

3
apps/web/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

75
apps/web/README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

45
apps/web/package.json Normal file
View 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
}
}

View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View 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 / SSTSDT), 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.23 for inverse calib; production often ~0.81.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` | NewtonRaphson |
| `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/`.

View 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 lair extérieur.
### Calibration
Z_UA = 1 par défaut ; vitesse ventilateur possible en régulation.
### JSON
Voir exemples CLI air-cooled.

View 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.

View 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 ≈ 12
```
| 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 = 13
```
| 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 (MagnusTetens 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, ṁ). Cest là quon **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.

View 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, GungorWinterton, Gnielinski, DittusBoelter, 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.

View 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` | 01 | 01 |
| `kv` | capacity | |
| characteristic | linear / … | linear |
---
## FR
### But
**Vanne de by-pass** sur une branche parallèle.
### Actionneur
Ouverture 01.
### JSON
Voir EN.

View 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 M1M10
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 |
| M3M6 | cooling power poly | 500, 1500, 2.5, 1.8 |
| M7M10 | 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, 1e5 |
| `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`).

View 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.

View 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_ish_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 M7M10) | 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.2e5 | 58000, 180, 280, 0.4 | 0.13 |
| `grasso_generic_200kw` | 1.30, 0.0035, 0.0022, 1e5 | 60000, 190, 310, 0.45 | 0.11 |
| (none) | 1.2, 0.003, 0.002, 1e5 | 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** M1M10 | ṁ(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, GungorWinterton | évaporation tubes (registre) |
| Gnielinski, DittusBoelter | monophasique |
| Ko 2021 | plaques low-GWP |
| Friedel 1979 | ΔP biphasique |
**Condenser / Evaporator / Flooded :** **UA global + ε-NTU** (pas Longo).
### Pompes / ventilateurs
Polynômes 1D QH / Qη + lois daffinité.
### Calibration
Tous les Z par défaut **1.0** (pas de correction).

View 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.

View 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.

View 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 / LongoShah 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** louverture EXV (contrôle).
### Calibration
`z_ua = 1` par défaut. Fixed SST + Z_UA libre pour calage.
### Ports / JSON
Voir EN.

View 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`.

View 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.

View 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 NuRe 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.

View 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 nest pas aligné sur FloodedEvaporator.
### Calibration
Z_UA = 1 si exposé.

View 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.

View 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.

View 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()`.

View 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.

View 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. EffectivenessNTU 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** (liquideliquide), sans cycle frigo.
### Calibration
Z_UA = 1 par défaut.

View 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 23 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.

View 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).

View 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"`.

View 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.

View 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.

View 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 denthalpie.
### Calibration
Facteur de perte de charge (défaut 1).
### JSON
Voir EN.

View 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 | HQ map | required |
| speed | operating speed | |
---
## FR
### But & modèle
**Pompe** liquide sur courbes HQ.
### Attention DoF
Ne pas imposer ṁ Source **et** courbe pompe sur la même branche.
### Ports / JSON
Voir EN.

View 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.

View File

@@ -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.

View 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`.

View 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 }
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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 }
}

View 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
}
}

View 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
}
}

View 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 }
}

View 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
}
}

View 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;
}
}

View 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
View 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>
);
}

View 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
lordre.
</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>
);
}

View 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 airwater" },
{ 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 dabord 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 lexemple">
<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>
);
}

View 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>
);
}

View 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");
});
});

View 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);
}

View 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
);
});

View 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>
);
}

View 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
sinsè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>
);
}

View 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>
);
}

View 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>
);
}

View 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 laxe"
>
<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>
);
}

View 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 nas 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,
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) : "—";
}

File diff suppressed because it is too large Load Diff

162
apps/web/src/lib/api.ts Normal file
View 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;
}
}

View 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);
});
});

View 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;
}

View 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_ish_suc)/η_is · ṁ = ρ·V·N·η_vol (émergent)";
case "Compressor":
return "Ahri540 : ṁ(M1M2,P,ρ,V,N) · Ẇ(M3M10) | 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_condT_sec) · UA global";
case "Evaporator":
case "EvaporatorCoil":
return "ε-NTU DX : Q = ε·C_sec·(T_secT_evap) · clôture superheat";
case "FloodedEvaporator":
return "ε-NTU noyé : Q = ε·C_sec·(T_secT_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;
}

View 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);
});
});

View 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) : "—";
}

View 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",
]),
);
}
});
});

File diff suppressed because it is too large Load Diff

View 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);
});
});

View 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;
}

View 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);
});
});

View 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 lAirSource, 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;
}

View 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");
});
});

View 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;
}

View 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);
});
});

View 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 };
}

View 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";
}
}

View 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");
});
});

View 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];
}

View 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);
});
});

View 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,
};
}

View 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);
});
});

View 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 };
}

View 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();
});
});

View 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,
}),
}));

View 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
View 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
View 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
View 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();
});

View File

@@ -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"

View 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}
}

View File

@@ -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 }
}

View 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
}
}

View 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
}
}

View 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