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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,26 @@ Coverage is enforced at ≥ 90 % by `pytest-cov`. The CI matrix runs on Python 3

---

## Reproduce the Published Result (one command)

All key parameters live in [`config/default.yaml`](config/default.yaml). To
reproduce the metrics and figure in [`results/`](results/) from a fresh clone:

```bash
git clone https://github.com/defnalk/cooltower.git
cd cooltower
pip install -e .
python -m cooltower.cli --config config/default.yaml
```

Outputs:

- `results/metrics.json` — psychrometric properties, energy balance, performance, control tuning
- `results/cooltower_results.png` — closed-loop response figure

To explore variations, copy `config/default.yaml`, edit any measurement or
FOPDT parameter, and pass the new file via `--config`.

## Running the Example

```bash
Expand Down
28 changes: 28 additions & 0 deletions config/default.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Reproducibility config for cooltower.
# Run: python -m cooltower.cli --config config/default.yaml

measurements:
air:
T_db_in_C: 24.5
T_wb_in_C: 18.0
T_db_out_C: 31.0
T_wb_out_C: 28.5
water:
T_hot_in_C: 39.5
T_cold_out_C: 27.5
m_in_kg_s: 0.48

control:
fopdt:
K_p: 0.82
tau_p_s: 145.0
theta_s: 18.0
setpoint_step_C: 5.0
t_end_s: 1200.0
dt_s: 2.0

output:
results_dir: results
metrics_name: metrics.json
figure_name: cooltower_results.png
random_seed: 0
Binary file added results/cooltower_results.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 30 additions & 0 deletions results/metrics.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"config": "config/default.yaml",
"psychrometrics": {
"omega_in_g_kg": 10.163800214389553,
"omega_out_g_kg": 23.742371736826417,
"h_in_kJ_kg": 50.516132991169115,
"h_out_kJ_kg": 91.89417612433698,
"rh_in": 0.52977601350517
},
"energy_balance": {
"m_air_kg_s": 0.5614978963945737,
"Q_air_kW": 23.233684176197695,
"Q_water_kW": 24.98903582380231,
"m_evap_kg_hr": 27.447621645209626,
"imbalance_W": 1755.351647604617
},
"performance": {
"range_C": 12.0,
"approach_C": 9.5,
"L_over_G": 0.8548562747645562
},
"control": {
"lambda": "PI(lambda): K_c=1.9539, \u03c4_I=145.00 s (K_I=0.0135)",
"ziegler_nichols": "PI(ziegler_nichols): K_c=8.8415, \u03c4_I=59.94 s (K_I=0.1475)",
"ISE": 1312.5722562479555,
"IAE": 437.49269702626566,
"ITAE": 31911.403796627314,
"y_final": 4.9999507126997065
}
}
146 changes: 146 additions & 0 deletions src/cooltower/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""
cooltower.cli — config-driven entry point for reproducible cooling-tower analysis.

Usage:
python -m cooltower.cli --config config/default.yaml
"""

from __future__ import annotations

import argparse
import json
import random
from pathlib import Path

import yaml

from cooltower.control import (
FOPDTModel,
closed_loop_response,
performance_indices,
tune_lambda,
tune_ziegler_nichols,
)
from cooltower.energy_balance import (
CoolingTowerState,
approach_temperature,
liquid_to_gas_ratio,
range_temperature,
solve_air_flow_rate,
solve_energy_balance,
)
from cooltower.psychrometrics import humidity_ratio, relative_humidity, specific_enthalpy


def load_config(path: Path) -> dict:
with path.open() as f:
return yaml.safe_load(f)


def run(config_path: Path) -> dict:
cfg = load_config(config_path)
random.seed(cfg["output"].get("random_seed", 0))

a = cfg["measurements"]["air"]
w = cfg["measurements"]["water"]

T_db1, T_wb1 = a["T_db_in_C"], a["T_wb_in_C"]
T_db2, T_wb2 = a["T_db_out_C"], a["T_wb_out_C"]
T_w3, T_w4 = w["T_hot_in_C"], w["T_cold_out_C"]
m_w3 = w["m_in_kg_s"]

omega1 = humidity_ratio(T_db1, T_wb1)
omega2 = humidity_ratio(T_db2, T_wb2)
h1 = specific_enthalpy(T_db1, omega1)
h2 = specific_enthalpy(T_db2, omega2)
rh1 = relative_humidity(T_db1, omega1)

m_air = solve_air_flow_rate(T_db1, T_wb1, T_db2, T_wb2, T_w3, T_w4, m_w3)

inlet = CoolingTowerState(T_water=T_w3, T_db=T_db1, T_wb=T_wb1, m_water=m_w3, m_air=m_air)
outlet = CoolingTowerState(T_water=T_w4, T_db=T_db2, T_wb=T_wb2, m_water=m_w3, m_air=m_air)
eb = solve_energy_balance(inlet, outlet)

rng = range_temperature(T_w3, T_w4)
appr = approach_temperature(T_w4, T_wb1)
lg = liquid_to_gas_ratio(m_w3, m_air)

cc = cfg["control"]["fopdt"]
model = FOPDTModel(K_p=cc["K_p"], tau_p=cc["tau_p_s"], theta=cc["theta_s"])
pi_lam = tune_lambda(model)
pi_zn = tune_ziegler_nichols(model)

sp = cfg["control"]["setpoint_step_C"]
t_end = cfg["control"]["t_end_s"]
dt = cfg["control"]["dt_s"]
t_lam, y_lam, _ = closed_loop_response(model, pi_lam, setpoint=sp, t_end=t_end, dt=dt)
e_lam = [sp - y for y in y_lam]
idx = performance_indices(t_lam, e_lam)

metrics = {
"config": str(config_path),
"psychrometrics": {
"omega_in_g_kg": omega1 * 1000,
"omega_out_g_kg": omega2 * 1000,
"h_in_kJ_kg": h1 / 1000,
"h_out_kJ_kg": h2 / 1000,
"rh_in": rh1,
},
"energy_balance": {
"m_air_kg_s": m_air,
"Q_air_kW": eb.Q_air / 1000,
"Q_water_kW": eb.Q_water / 1000,
"m_evap_kg_hr": eb.m_evap * 3600,
"imbalance_W": eb.imbalance,
},
"performance": {
"range_C": rng,
"approach_C": appr,
"L_over_G": lg,
},
"control": {
"lambda": str(pi_lam),
"ziegler_nichols": str(pi_zn),
"ISE": idx["ISE"],
"IAE": idx["IAE"],
"ITAE": idx["ITAE"],
"y_final": y_lam[-1],
},
}

out_dir = Path(cfg["output"]["results_dir"])
out_dir.mkdir(parents=True, exist_ok=True)
metrics_path = out_dir / cfg["output"]["metrics_name"]
with metrics_path.open("w") as f:
json.dump(metrics, f, indent=2, default=str)

_render_figure(t_lam, y_lam, sp, out_dir / cfg["output"]["figure_name"])
print(f"✓ metrics → {metrics_path}")
print(f"✓ figure → {out_dir / cfg['output']['figure_name']}")
return metrics


def _render_figure(t, y, sp, out_path: Path) -> None:
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(t, y, lw=2, label="Closed-loop response")
ax.axhline(sp, ls="--", color="grey", label=f"setpoint = {sp}")
ax.set_xlabel("Time (s)")
ax.set_ylabel("Output (°C)")
ax.set_title("Cooltower — λ-tuned PI closed-loop response")
ax.legend()
ax.grid(alpha=0.3)
fig.savefig(out_path, dpi=150, bbox_inches="tight")
plt.close(fig)


def main() -> None:
parser = argparse.ArgumentParser(prog="python -m cooltower.cli")
parser.add_argument("--config", type=Path, default=Path("config/default.yaml"))
args = parser.parse_args()
run(args.config)


if __name__ == "__main__":
main()
Loading