ventilatepro-cli 0.5.2__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/PKG-INFO +21 -2
  2. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/README.md +18 -1
  3. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/pyproject.toml +2 -0
  4. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/tests/test_cli.py +77 -1
  5. ventilatepro_cli-0.7.0/tests/test_hvac_calculations.py +220 -0
  6. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/tests/test_mcp_server.py +81 -0
  7. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/__init__.py +1 -1
  8. ventilatepro_cli-0.7.0/ventilatepro/calc.py +422 -0
  9. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/cli.py +25 -0
  10. ventilatepro_cli-0.7.0/ventilatepro/entity_catalog.py +124 -0
  11. ventilatepro_cli-0.7.0/ventilatepro/equipment.py +49 -0
  12. ventilatepro_cli-0.7.0/ventilatepro/esp.py +77 -0
  13. ventilatepro_cli-0.7.0/ventilatepro/hvac_calculations.py +516 -0
  14. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/mcp_server.py +255 -43
  15. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/PKG-INFO +21 -2
  16. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/SOURCES.txt +5 -0
  17. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/requires.txt +2 -0
  18. ventilatepro_cli-0.5.2/ventilatepro/calc.py +0 -142
  19. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/setup.cfg +0 -0
  20. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/tests/test_config.py +0 -0
  21. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/__main__.py +0 -0
  22. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/api_client.py +0 -0
  23. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/auth.py +0 -0
  24. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/categorization.py +0 -0
  25. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/config.py +0 -0
  26. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/decisions.py +0 -0
  27. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/design_day.py +0 -0
  28. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/entities.py +0 -0
  29. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/mcp_cli.py +0 -0
  30. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/meetings.py +0 -0
  31. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/notes.py +0 -0
  32. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/output.py +0 -0
  33. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/payloads.py +0 -0
  34. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/projects.py +0 -0
  35. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/queue.py +0 -0
  36. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/revit_imports.py +0 -0
  37. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/systems.py +0 -0
  38. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro/tasks.py +0 -0
  39. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
  40. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
  41. {ventilatepro_cli-0.5.2 → ventilatepro_cli-0.7.0}/ventilatepro_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ventilatepro-cli
3
- Version: 0.5.2
3
+ Version: 0.7.0
4
4
  Summary: VentilatePro CLI
5
5
  Project-URL: Homepage, https://ventilatepro.com/exam/cli/
6
6
  Project-URL: Documentation, https://ventilatepro.com/exam/cli/
@@ -12,11 +12,18 @@ Requires-Dist: requests>=2.32.0
12
12
  Requires-Dist: keyring>=25.6.0
13
13
  Requires-Dist: platformdirs>=4.3.0
14
14
  Requires-Dist: mcp<2.0.0,>=1.23.0
15
+ Requires-Dist: PsychroLib==2.5.0
16
+ Requires-Dist: CoolProp==8.0.0
15
17
 
16
18
  # VentilatePro CLI
17
19
 
18
20
  VentilatePro CLI is a local command-line client for authenticating to VentilatePro, recording project notes and meeting minutes, and running scoped engineering workflows.
19
21
 
22
+ It also includes offline HVAC calculations for PsychroLib-based psychrometrics,
23
+ sensible and total air loads, complete air-process balances, hydronics,
24
+ IAPWS-IF97 saturated steam through CoolProp 8.0.0, fan and pump power, duct sizing, and LMTD. Local
25
+ calculations require no login and are also available as typed MCP tools.
26
+
20
27
  ## Install
21
28
 
22
29
  Published releases install from PyPI:
@@ -55,6 +62,10 @@ ventilatepro ahus list --project 1
55
62
  ventilatepro systems hierarchy --project 1
56
63
  ventilatepro calc status --project 1
57
64
  ventilatepro calc ahu-properties --ahu 1
65
+ ventilatepro calc psychrometrics --dry-bulb 75 --relative-humidity 50 --json
66
+ ventilatepro calc air-process --cfm 5000 --entering-dry-bulb 80 --entering-rh 50 --leaving-dry-bulb 55 --leaving-rh 95 --json
67
+ ventilatepro calc steam --pressure 15 --pressure-units psig --load 1000000 --json
68
+ ventilatepro calc fan-power --cfm 20000 --static-pressure 4 --fan-efficiency 0.68 --motor-efficiency 0.92
58
69
  ventilatepro design-day context --project 1
59
70
  ventilatepro notes create --project 1 --body "Captured from terminal"
60
71
  ventilatepro notes sync
@@ -74,6 +85,10 @@ ventilatepro-mcp
74
85
  and other MCP clients. It uses the same saved base URL and CLI token created by
75
86
  `ventilatepro auth login`.
76
87
 
88
+ Local HVAC tools (`vp_calculate_psychrometrics`,
89
+ `vp_calculate_air_process`, `vp_calculate_steam`, and the rest of the catalog)
90
+ do not use that login or make network requests.
91
+
77
92
  For meeting capture, Codex first reads `meetings context` to resolve names to
78
93
  stable project-member IDs, interprets the PM's prose into a structured JSON
79
94
  payload, and calls `meetings record`. The server atomically creates the meeting,
@@ -154,4 +169,8 @@ or pipe the token through stdin:
154
169
  printf '%s' "$VPCLI_TOKEN" | ventilatepro auth login --token-stdin
155
170
  ```
156
171
 
157
- See the hosted install guide at `https://ventilatepro.com/exam/cli/`, plus [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md), [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
172
+ See the hosted install guide at `https://ventilatepro.com/exam/cli/`, the HVAC
173
+ calculation reference at `https://ventilatepro.com/exam/cli/calculations/`, the
174
+ machine-readable index at `https://ventilatepro.com/llms.txt`, plus
175
+ [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md),
176
+ [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
@@ -2,6 +2,11 @@
2
2
 
3
3
  VentilatePro CLI is a local command-line client for authenticating to VentilatePro, recording project notes and meeting minutes, and running scoped engineering workflows.
4
4
 
5
+ It also includes offline HVAC calculations for PsychroLib-based psychrometrics,
6
+ sensible and total air loads, complete air-process balances, hydronics,
7
+ IAPWS-IF97 saturated steam through CoolProp 8.0.0, fan and pump power, duct sizing, and LMTD. Local
8
+ calculations require no login and are also available as typed MCP tools.
9
+
5
10
  ## Install
6
11
 
7
12
  Published releases install from PyPI:
@@ -40,6 +45,10 @@ ventilatepro ahus list --project 1
40
45
  ventilatepro systems hierarchy --project 1
41
46
  ventilatepro calc status --project 1
42
47
  ventilatepro calc ahu-properties --ahu 1
48
+ ventilatepro calc psychrometrics --dry-bulb 75 --relative-humidity 50 --json
49
+ ventilatepro calc air-process --cfm 5000 --entering-dry-bulb 80 --entering-rh 50 --leaving-dry-bulb 55 --leaving-rh 95 --json
50
+ ventilatepro calc steam --pressure 15 --pressure-units psig --load 1000000 --json
51
+ ventilatepro calc fan-power --cfm 20000 --static-pressure 4 --fan-efficiency 0.68 --motor-efficiency 0.92
43
52
  ventilatepro design-day context --project 1
44
53
  ventilatepro notes create --project 1 --body "Captured from terminal"
45
54
  ventilatepro notes sync
@@ -59,6 +68,10 @@ ventilatepro-mcp
59
68
  and other MCP clients. It uses the same saved base URL and CLI token created by
60
69
  `ventilatepro auth login`.
61
70
 
71
+ Local HVAC tools (`vp_calculate_psychrometrics`,
72
+ `vp_calculate_air_process`, `vp_calculate_steam`, and the rest of the catalog)
73
+ do not use that login or make network requests.
74
+
62
75
  For meeting capture, Codex first reads `meetings context` to resolve names to
63
76
  stable project-member IDs, interprets the PM's prose into a structured JSON
64
77
  payload, and calls `meetings record`. The server atomically creates the meeting,
@@ -139,4 +152,8 @@ or pipe the token through stdin:
139
152
  printf '%s' "$VPCLI_TOKEN" | ventilatepro auth login --token-stdin
140
153
  ```
141
154
 
142
- See the hosted install guide at `https://ventilatepro.com/exam/cli/`, plus [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md), [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
155
+ See the hosted install guide at `https://ventilatepro.com/exam/cli/`, the HVAC
156
+ calculation reference at `https://ventilatepro.com/exam/cli/calculations/`, the
157
+ machine-readable index at `https://ventilatepro.com/llms.txt`, plus
158
+ [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md),
159
+ [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
@@ -14,6 +14,8 @@ dependencies = [
14
14
  "keyring>=25.6.0",
15
15
  "platformdirs>=4.3.0",
16
16
  "mcp>=1.23.0,<2.0.0",
17
+ "PsychroLib==2.5.0",
18
+ "CoolProp==8.0.0",
17
19
  ]
18
20
 
19
21
  [project.urls]
@@ -6,7 +6,7 @@ from unittest import mock
6
6
 
7
7
  from typer.testing import CliRunner
8
8
 
9
- from ventilatepro import auth, config
9
+ from ventilatepro import __version__, auth, config
10
10
  from ventilatepro.api_client import ApiClient, ApiError, NetworkError
11
11
  from ventilatepro.cli import app
12
12
  from ventilatepro.categorization import _classification_group_key
@@ -18,6 +18,12 @@ class CliTests(unittest.TestCase):
18
18
  def setUp(self):
19
19
  self.runner = CliRunner()
20
20
 
21
+ def test_version_option_reports_installed_package_version(self):
22
+ result = self.runner.invoke(app, ["--version"])
23
+
24
+ self.assertEqual(result.exit_code, 0, result.output)
25
+ self.assertEqual(result.stdout.strip(), __version__)
26
+
21
27
  def test_auth_login_reads_token_from_stdin(self):
22
28
  with tempfile.TemporaryDirectory() as tmpdir:
23
29
  os.environ[config.CONFIG_ENV_VAR] = tmpdir
@@ -163,6 +169,76 @@ class CliTests(unittest.TestCase):
163
169
  self.assertEqual(result.exit_code, 1)
164
170
  self.assertIn("FAIL saved_login", result.output)
165
171
 
172
+ def test_equipment_create_routes_supported_type_through_cli_proxy(self):
173
+ fake_client = mock.Mock()
174
+ fake_client.cli_proxy.return_value = {"id": 81, "project": 7, "tag": "EF-CLI-1"}
175
+
176
+ with mock.patch.object(
177
+ auth,
178
+ "get_authenticated_client",
179
+ return_value=(fake_client, "http://localhost:8000"),
180
+ ):
181
+ result = self.runner.invoke(
182
+ app,
183
+ [
184
+ "equipment",
185
+ "create",
186
+ "exhaust-fans",
187
+ "--data",
188
+ '{"project":7,"tag":"EF-CLI-1"}',
189
+ "--json",
190
+ ],
191
+ )
192
+
193
+ self.assertEqual(result.exit_code, 0, result.output)
194
+ fake_client.cli_proxy.assert_called_once_with(
195
+ "POST",
196
+ "exhaust-fans/",
197
+ json_payload={"project": 7, "tag": "EF-CLI-1"},
198
+ )
199
+
200
+ def test_esp_commands_create_every_path_shape_and_components(self):
201
+ fake_client = mock.Mock()
202
+ fake_client.cli_proxy.side_effect = [
203
+ {"id": 91, "fan_type": "SUPPLY", "ahu": 12},
204
+ {"id": 92, "fan_type": "RETURN", "ahu": 12},
205
+ {"id": 93, "fan_type": "EXHAUST", "exhaust_fan": 18},
206
+ {"id": 94, "fan_type": "STANDALONE", "project": 7},
207
+ {"id": 101, "calculation": 91, "component_type": "FILTER_MERV13"},
208
+ ]
209
+ payloads = [
210
+ {"project": 7, "ahu": 12, "fan_type": "SUPPLY", "name": "Supply path"},
211
+ {"project": 7, "ahu": 12, "fan_type": "RETURN", "name": "Return path"},
212
+ {"project": 7, "exhaust_fan": 18, "fan_type": "EXHAUST", "name": "Exhaust path"},
213
+ {"project": 7, "fan_type": "STANDALONE", "name": "Standalone path"},
214
+ ]
215
+
216
+ with mock.patch.object(
217
+ auth,
218
+ "get_authenticated_client",
219
+ return_value=(fake_client, "http://localhost:8000"),
220
+ ):
221
+ for payload in payloads:
222
+ result = self.runner.invoke(
223
+ app,
224
+ ["esp", "create", "--data", json.dumps(payload), "--json"],
225
+ )
226
+ self.assertEqual(result.exit_code, 0, result.output)
227
+ component = {
228
+ "calculation": 91,
229
+ "component_type": "FILTER_MERV13",
230
+ "description": "Final filter",
231
+ "pressure_drop_iwc": 0.5,
232
+ }
233
+ result = self.runner.invoke(
234
+ app,
235
+ ["esp", "add-component", "--data", json.dumps(component), "--json"],
236
+ )
237
+
238
+ self.assertEqual(result.exit_code, 0, result.output)
239
+ paths = [call.args[1] for call in fake_client.cli_proxy.call_args_list]
240
+ self.assertEqual(paths, ["esp-calculations/"] * 4 + ["esp-components/"])
241
+
166
242
  def test_notes_create_queues_on_network_error(self):
167
243
  with tempfile.TemporaryDirectory() as tmpdir:
168
244
  os.environ[config.CONFIG_ENV_VAR] = tmpdir
@@ -0,0 +1,220 @@
1
+ import json
2
+ import math
3
+ import unittest
4
+ from concurrent.futures import ThreadPoolExecutor
5
+
6
+ from typer.testing import CliRunner
7
+
8
+ from ventilatepro.cli import app
9
+ from ventilatepro.hvac_calculations import (
10
+ calculate_air_process,
11
+ calculate_duct_size,
12
+ calculate_fan_power,
13
+ calculate_hydronic_flow,
14
+ calculate_hydronic_load,
15
+ calculate_lmtd,
16
+ calculate_psychrometrics,
17
+ calculate_pump_power,
18
+ calculate_saturated_steam,
19
+ calculate_sensible_load,
20
+ calculate_total_load,
21
+ )
22
+
23
+
24
+ class HvacCalculationTests(unittest.TestCase):
25
+ def test_psychrometrics_matches_published_psychrolib_example(self):
26
+ result = calculate_psychrometrics(
27
+ dry_bulb=25.0,
28
+ relative_humidity=80.0,
29
+ pressure=101325.0,
30
+ units="si",
31
+ )
32
+
33
+ self.assertAlmostEqual(result["values"]["dew_point"], 21.309397, places=5)
34
+ self.assertAlmostEqual(result["values"]["relative_humidity"], 80.0, places=7)
35
+ self.assertEqual(result["units"]["enthalpy"], "J/kg_dry_air")
36
+
37
+ def test_psychrometrics_accepts_each_supported_input_pair(self):
38
+ base = calculate_psychrometrics(dry_bulb=75, relative_humidity=50, units="ip")
39
+ values = base["values"]
40
+
41
+ for keyword, value in (
42
+ ("wet_bulb", values["wet_bulb"]),
43
+ ("dew_point", values["dew_point"]),
44
+ ("humidity_ratio", values["humidity_ratio"]),
45
+ ):
46
+ resolved = calculate_psychrometrics(dry_bulb=75, units="ip", **{keyword: value})
47
+ # PsychroLib's wet-bulb inverse solver uses its documented
48
+ # convergence tolerance, so a round trip can differ by a few
49
+ # thousandths of a percentage point.
50
+ self.assertAlmostEqual(resolved["values"]["relative_humidity"], 50.0, delta=0.01)
51
+ self.assertAlmostEqual(resolved["values"]["enthalpy"], values["enthalpy"], delta=0.001)
52
+
53
+ def test_psychrometrics_rejects_ambiguous_inputs(self):
54
+ with self.assertRaisesRegex(ValueError, "exactly one"):
55
+ calculate_psychrometrics(
56
+ dry_bulb=75,
57
+ relative_humidity=50,
58
+ wet_bulb=62,
59
+ )
60
+
61
+ def test_psychrometrics_accepts_dry_air_and_is_thread_safe_across_units(self):
62
+ dry_air = calculate_psychrometrics(
63
+ dry_bulb=75,
64
+ relative_humidity=0,
65
+ units="ip",
66
+ )
67
+ self.assertAlmostEqual(dry_air["values"]["relative_humidity"], 0.0)
68
+
69
+ def calculate(index):
70
+ if index % 2:
71
+ return calculate_psychrometrics(
72
+ dry_bulb=25,
73
+ relative_humidity=80,
74
+ pressure=101325,
75
+ units="si",
76
+ )
77
+ return calculate_psychrometrics(
78
+ dry_bulb=77,
79
+ relative_humidity=80,
80
+ pressure=14.6959488,
81
+ units="ip",
82
+ )
83
+
84
+ with ThreadPoolExecutor(max_workers=8) as executor:
85
+ results = list(executor.map(calculate, range(40)))
86
+
87
+ for index, result in enumerate(results):
88
+ expected_units = "si" if index % 2 else "ip"
89
+ self.assertEqual(result["unit_system"], expected_units)
90
+ self.assertAlmostEqual(result["values"]["relative_humidity"], 80.0, places=5)
91
+
92
+ def test_standard_air_load_formulas(self):
93
+ sensible = calculate_sensible_load(
94
+ airflow_cfm=1000,
95
+ entering_dry_bulb_f=80,
96
+ leaving_dry_bulb_f=60,
97
+ )
98
+ total = calculate_total_load(
99
+ airflow_cfm=1000,
100
+ entering_enthalpy_btu_per_lb=35,
101
+ leaving_enthalpy_btu_per_lb=25,
102
+ )
103
+
104
+ self.assertAlmostEqual(sensible["values"]["sensible_load_btu_per_hr"], 21600)
105
+ self.assertAlmostEqual(total["values"]["total_load_btu_per_hr"], 45000)
106
+
107
+ def test_air_process_reconciles_sensible_latent_and_total(self):
108
+ result = calculate_air_process(
109
+ airflow_cfm=5000,
110
+ entering_dry_bulb_f=80,
111
+ entering_relative_humidity=50,
112
+ leaving_dry_bulb_f=55,
113
+ leaving_relative_humidity=95,
114
+ )
115
+ values = result["values"]
116
+
117
+ self.assertAlmostEqual(
118
+ values["sensible_load_btu_per_hr"] + values["latent_load_btu_per_hr"],
119
+ values["total_load_btu_per_hr"],
120
+ places=6,
121
+ )
122
+ self.assertGreater(values["dry_air_mass_flow_lb_per_hr"], 0)
123
+ self.assertGreaterEqual(values["condensate_gpm"], 0)
124
+
125
+ def test_hydronic_load_and_inverse_flow_reconcile(self):
126
+ load = calculate_hydronic_load(flow_gpm=10, delta_t_f=20)
127
+ flow = calculate_hydronic_flow(
128
+ load_btu_per_hr=load["values"]["load_btu_per_hr"],
129
+ delta_t_f=20,
130
+ )
131
+
132
+ self.assertAlmostEqual(load["values"]["load_btu_per_hr"], 99960)
133
+ self.assertAlmostEqual(flow["values"]["flow_gpm"], 10)
134
+
135
+ def test_saturated_steam_matches_boiling_point_at_zero_psig(self):
136
+ result = calculate_saturated_steam(
137
+ pressure=0,
138
+ pressure_units="psig",
139
+ quality=1,
140
+ load_btu_per_hr=970_000,
141
+ )
142
+ values = result["values"]
143
+
144
+ self.assertAlmostEqual(values["saturation_temperature_f"], 212.0, delta=0.08)
145
+ self.assertAlmostEqual(values["latent_heat_btu_per_lb"], 970.3, delta=1.0)
146
+ self.assertAlmostEqual(values["required_steam_lb_per_hr"], 1000, delta=2.0)
147
+
148
+ def test_fan_pump_duct_and_lmtd_calculations(self):
149
+ fan = calculate_fan_power(
150
+ airflow_cfm=10_000,
151
+ static_pressure_in_wg=3,
152
+ fan_efficiency=0.65,
153
+ motor_efficiency=0.9,
154
+ )
155
+ pump = calculate_pump_power(
156
+ flow_gpm=500,
157
+ head_ft=60,
158
+ pump_efficiency=0.75,
159
+ motor_efficiency=0.9,
160
+ )
161
+ duct = calculate_duct_size(airflow_cfm=1200, velocity_fpm=1000, aspect_ratio=2)
162
+ lmtd = calculate_lmtd(
163
+ hot_in_f=200,
164
+ hot_out_f=150,
165
+ cold_in_f=80,
166
+ cold_out_f=120,
167
+ arrangement="counterflow",
168
+ )
169
+
170
+ self.assertGreater(fan["values"]["input_kw"], 0)
171
+ self.assertGreater(pump["values"]["input_kw"], 0)
172
+ self.assertAlmostEqual(duct["values"]["area_sq_ft"], 1.2)
173
+ self.assertAlmostEqual(duct["values"]["round_diameter_in"], math.sqrt(4.8 / math.pi) * 12)
174
+ self.assertAlmostEqual(lmtd["values"]["lmtd_f"], (80 - 70) / math.log(80 / 70))
175
+
176
+
177
+ class HvacCalculationCliTests(unittest.TestCase):
178
+ def setUp(self):
179
+ self.runner = CliRunner()
180
+
181
+ def test_psychrometrics_command_runs_offline_and_emits_json(self):
182
+ result = self.runner.invoke(
183
+ app,
184
+ [
185
+ "calc",
186
+ "psychrometrics",
187
+ "--dry-bulb",
188
+ "75",
189
+ "--relative-humidity",
190
+ "50",
191
+ "--json",
192
+ ],
193
+ )
194
+
195
+ self.assertEqual(result.exit_code, 0, result.output)
196
+ payload = json.loads(result.output)
197
+ self.assertEqual(payload["calculation"], "psychrometrics")
198
+ self.assertAlmostEqual(payload["values"]["relative_humidity"], 50.0, places=5)
199
+
200
+ def test_invalid_psychrometric_pair_exits_nonzero(self):
201
+ result = self.runner.invoke(
202
+ app,
203
+ [
204
+ "calc",
205
+ "psychrometrics",
206
+ "--dry-bulb",
207
+ "75",
208
+ "--relative-humidity",
209
+ "50",
210
+ "--wet-bulb",
211
+ "62",
212
+ ],
213
+ )
214
+
215
+ self.assertNotEqual(result.exit_code, 0)
216
+ self.assertIn("exactly one", result.output)
217
+
218
+
219
+ if __name__ == "__main__":
220
+ unittest.main()
@@ -107,8 +107,22 @@ class McpServerTests(unittest.TestCase):
107
107
  tools = await server.list_tools()
108
108
  names = {tool.name for tool in tools}
109
109
  self.assertIn("vp_whoami", names)
110
+ self.assertIn("vp_list_hvac_calculations", names)
111
+ self.assertIn("vp_calculate_psychrometrics", names)
112
+ self.assertIn("vp_calculate_sensible_load", names)
113
+ self.assertIn("vp_calculate_total_load", names)
114
+ self.assertIn("vp_calculate_air_process", names)
115
+ self.assertIn("vp_calculate_hydronic", names)
116
+ self.assertIn("vp_calculate_steam", names)
117
+ self.assertIn("vp_calculate_fan_power", names)
118
+ self.assertIn("vp_calculate_pump_power", names)
119
+ self.assertIn("vp_calculate_duct_size", names)
120
+ self.assertIn("vp_calculate_lmtd", names)
110
121
  self.assertIn("vp_api_request", names)
111
122
  self.assertIn("vp_list_entities", names)
123
+ self.assertIn("vp_create_equipment", names)
124
+ self.assertIn("vp_create_esp_calculation", names)
125
+ self.assertIn("vp_create_esp_component", names)
112
126
  self.assertIn("vp_run_workflow", names)
113
127
  self.assertIn("vp_get_categorization_review", names)
114
128
  self.assertIn("vp_apply_categorization", names)
@@ -128,6 +142,73 @@ class McpServerTests(unittest.TestCase):
128
142
 
129
143
  asyncio.run(run())
130
144
 
145
+ def test_local_psychrometric_tool_does_not_require_authentication(self):
146
+ async def run():
147
+ server = build_server()
148
+ with mock.patch(
149
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
150
+ side_effect=AssertionError("local calculations must not authenticate"),
151
+ ):
152
+ _content, structured = await server.call_tool(
153
+ "vp_calculate_psychrometrics",
154
+ {"dry_bulb": 75, "relative_humidity": 50, "units": "ip"},
155
+ )
156
+
157
+ result = structured["result"]
158
+ self.assertEqual(result["calculation"], "psychrometrics")
159
+ self.assertAlmostEqual(result["values"]["relative_humidity"], 50.0, places=5)
160
+
161
+ asyncio.run(run())
162
+
163
+ def test_typed_equipment_and_esp_tools_route_to_supported_entities(self):
164
+ async def run():
165
+ server = build_server()
166
+ fake_client = FakeClient()
167
+ with mock.patch(
168
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
169
+ return_value=(fake_client, "http://localhost:8000"),
170
+ ):
171
+ await server.call_tool(
172
+ "vp_create_equipment",
173
+ {
174
+ "equipment_type": "condensing-units",
175
+ "payload": {"project": 7, "name": "CU-1"},
176
+ },
177
+ )
178
+ await server.call_tool(
179
+ "vp_create_esp_calculation",
180
+ {
181
+ "payload": {
182
+ "project": 7,
183
+ "ahu": 12,
184
+ "fan_type": "SUPPLY",
185
+ "name": "Longest supply path",
186
+ },
187
+ },
188
+ )
189
+ await server.call_tool(
190
+ "vp_create_esp_component",
191
+ {
192
+ "payload": {
193
+ "calculation": 91,
194
+ "component_type": "FILTER_MERV13",
195
+ "description": "Final filter",
196
+ "pressure_drop_iwc": 0.5,
197
+ },
198
+ },
199
+ )
200
+
201
+ self.assertEqual(
202
+ [call[:2] for call in fake_client.calls],
203
+ [
204
+ ("POST", "condensing-units/"),
205
+ ("POST", "esp-calculations/"),
206
+ ("POST", "esp-components/"),
207
+ ],
208
+ )
209
+
210
+ asyncio.run(run())
211
+
131
212
  def test_record_meeting_tool_saves_without_confirmation(self):
132
213
  async def run():
133
214
  server = build_server()
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.5.2"
5
+ __version__ = "0.7.0"