watertrain 1.0.0__py3-none-any.whl

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.
watertrain/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """watertrain — Automatic Water-Treatment-Plant Flowsheet Generator.
2
+
3
+ Python port of the validated Excel/VBA tool (WTP_FlowsheetGenerator.xlsm).
4
+
5
+ Typical use::
6
+
7
+ from watertrain import WaterCase, generate, rank, design
8
+
9
+ case = WaterCase.create(
10
+ feed=dict(turbidity=10, tss=15, oil_grease=1, fe=0.3, mn=0.1,
11
+ cod=40, toc=10, tds=5384, sio2=30, hardness=193),
12
+ target=dict(turbidity=1, tss=1, oil_grease=0.1, fe=0.1, mn=0.05,
13
+ cod=10, toc=2, tds=100, sio2=5, hardness=20),
14
+ product_flow=19.3, source="Treated Wastewater/Sewage",
15
+ max_train_len=9, max_flowsheets=10)
16
+
17
+ flowsheets = generate(case) # every feasible train
18
+ best = rank(flowsheets, top_x=5) # cheapest first (TAC)
19
+ print(design(best[0], case).report())
20
+
21
+ Workflow: generate -> rank -> design (same as the workbook macros
22
+ GenerateFlowsheets -> RankFlowsheets -> DesignSelected).
23
+ """
24
+ from .model import (NO_LIMIT, NP, PARAMS, Flowsheet, Technology, WaterCase)
25
+ from .database import DEFAULT_TECHNOLOGIES, TECH_BY_NAME
26
+ from .engine import generate, rank
27
+ from .design import Design, Equipment, Stream, design
28
+ from .examples import EXAMPLES, get_example
29
+ from .sfiles import sfiles_string, sfiles_tag
30
+
31
+ __version__ = "1.0.0"
32
+
33
+ __all__ = [
34
+ "PARAMS", "NP", "NO_LIMIT",
35
+ "Technology", "WaterCase", "Flowsheet",
36
+ "DEFAULT_TECHNOLOGIES", "TECH_BY_NAME",
37
+ "generate", "rank", "design",
38
+ "Design", "Equipment", "Stream",
39
+ "EXAMPLES", "get_example",
40
+ "sfiles_string", "sfiles_tag",
41
+ "__version__",
42
+ ]
watertrain/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """Command-line interface.
2
+
3
+ Examples::
4
+
5
+ watertrain --list-examples
6
+ watertrain --example 6 --top 5
7
+ watertrain --example 6 --top 5 --design 1
8
+ watertrain --case mycase.json --top 10 --design 2
9
+
10
+ mycase.json format::
11
+
12
+ {
13
+ "feed": {"turbidity": 10, "tss": 15, "tds": 5384, "...": 0},
14
+ "target": {"turbidity": 1, "tss": 1, "tds": 100, "...": 0},
15
+ "product_flow": 19.3,
16
+ "source": "Treated Wastewater/Sewage",
17
+ "max_train_len": 9,
18
+ "max_flowsheets": 200
19
+ }
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import sys
26
+
27
+ from . import __version__
28
+ from .design import design as design_fs
29
+ from .engine import generate, rank
30
+ from .examples import EXAMPLES, get_example
31
+ from .model import WaterCase
32
+
33
+
34
+ def _load_case_json(path: str) -> WaterCase:
35
+ with open(path, "r", encoding="utf-8") as fh:
36
+ d = json.load(fh)
37
+ return WaterCase.create(
38
+ feed=d["feed"], target=d["target"],
39
+ product_flow=d["product_flow"], source=d.get("source", ""),
40
+ max_train_len=d.get("max_train_len", 7),
41
+ max_flowsheets=d.get("max_flowsheets", 200),
42
+ name=d.get("name", path))
43
+
44
+
45
+ def main(argv=None) -> int:
46
+ ap = argparse.ArgumentParser(
47
+ prog="watertrain",
48
+ description="Automatic WTP flowsheet generator "
49
+ "(generate -> rank -> design).")
50
+ ap.add_argument("--version", action="version", version=__version__)
51
+ ap.add_argument("--list-examples", action="store_true",
52
+ help="list the built-in example cases and exit")
53
+ src = ap.add_mutually_exclusive_group()
54
+ src.add_argument("--example", metavar="N",
55
+ help="use built-in example (1-7 or full name)")
56
+ src.add_argument("--case", metavar="FILE.json",
57
+ help="load a case from a JSON file")
58
+ ap.add_argument("--top", type=int, default=10, metavar="X",
59
+ help="rank: keep the X cheapest trains (default 10)")
60
+ ap.add_argument("--design", type=int, metavar="RANK",
61
+ help="print the full design of the given rank (1 = best)")
62
+ ap.add_argument("--max-flowsheets", type=int, metavar="M",
63
+ help="override the enumeration cap")
64
+ args = ap.parse_args(argv)
65
+
66
+ if args.list_examples:
67
+ for name in EXAMPLES:
68
+ print(name)
69
+ return 0
70
+
71
+ if args.example:
72
+ case = get_example(args.example)
73
+ elif args.case:
74
+ case = _load_case_json(args.case)
75
+ else:
76
+ ap.error("choose --example N or --case FILE.json "
77
+ "(or --list-examples)")
78
+
79
+ if args.max_flowsheets:
80
+ case = WaterCase.create(
81
+ feed=case.feed_dict(), target=case.target_dict(),
82
+ product_flow=case.product_flow, source=case.source,
83
+ max_train_len=case.max_train_len,
84
+ max_flowsheets=args.max_flowsheets, name=case.name)
85
+
86
+ fss = generate(case)
87
+ print(f"Case: {case.name or '(custom)'} | "
88
+ f"generated {len(fss)} feasible flowsheet(s)")
89
+ top = rank(fss, top_x=args.top)
90
+ print(f"\nTop {len(top)} by TAC:")
91
+ print(f"{'#':>3} {'TAC':>8} {'Rec%':>6} {'Rel%':>6} {'Intake':>8} SFILES")
92
+ for r, fs in enumerate(top, 1):
93
+ print(f"{r:>3} {fs.tac:8.3f} {fs.recovery_pct:6.1f} "
94
+ f"{fs.reliability_pct:6.1f} {fs.intake:8.2f} {fs.sfiles}")
95
+
96
+ if args.design:
97
+ if not (1 <= args.design <= len(top)):
98
+ print(f"\n--design {args.design} is out of range (1..{len(top)})",
99
+ file=sys.stderr)
100
+ return 2
101
+ print("\n" + "=" * 70)
102
+ print(design_fs(top[args.design - 1], case).report())
103
+ return 0
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(main())
watertrain/database.py ADDED
@@ -0,0 +1,75 @@
1
+ """Default technology database.
2
+
3
+ Ported 1:1 from sheet '2.ProcessGroups' of WTP_FlowsheetGenerator.xlsm.
4
+ rem_* = removal fraction (0-1); in_* = max inlet the unit tolerates
5
+ (absent = no limit). Stage: 1 pre-treatment, 2 filtration, 3 RO, 4 polish.
6
+
7
+ Users can pass their own list of Technology objects to generate(); this
8
+ module is only the built-in default.
9
+ """
10
+ from .model import Technology
11
+
12
+ T = Technology.create
13
+
14
+ DEFAULT_TECHNOLOGIES = [
15
+ T("Clarifier", 1, 0.991, 0.35, 1.6, 0.5,
16
+ removal=dict(turbidity=0.98, tss=0.97, oil_grease=0.9, fe=0.8, mn=0.6,
17
+ cod=0.4, toc=0.35, tds=0.0, sio2=0.1, hardness=0.0)),
18
+ T("DAF", 1, 0.99, 0.35, 1.7, 0.8,
19
+ removal=dict(turbidity=0.9, tss=0.92, oil_grease=0.95,
20
+ cod=0.35, toc=0.25)),
21
+ T("HRSCC", 1, 0.985, 0.35, 2.2, 1.0,
22
+ removal=dict(turbidity=0.985, tss=0.98, fe=0.85, mn=0.7,
23
+ cod=0.45, toc=0.4, sio2=0.3, hardness=0.6)),
24
+ T("ABF", 2, 0.992, 0.30, 0.8, 0.2,
25
+ removal=dict(turbidity=0.6, tss=0.7, fe=0.4, mn=0.3),
26
+ inlet_max=dict(turbidity=50, tss=50)),
27
+ T("MMF", 2, 0.97, 0.30, 1.0, 0.3,
28
+ removal=dict(turbidity=0.8, tss=0.85, oil_grease=0.3, fe=0.5, mn=0.4,
29
+ cod=0.05, toc=0.05),
30
+ inlet_max=dict(turbidity=50, tss=50)),
31
+ T("GSF", 2, 0.973, 0.30, 1.1, 0.35,
32
+ removal=dict(turbidity=0.9, tss=0.92, oil_grease=0.3, fe=0.85, mn=0.8,
33
+ cod=0.05, toc=0.05),
34
+ inlet_max=dict(turbidity=50, tss=50)),
35
+ T("ACF", 2, 0.93, 0.35, 1.2, 0.6,
36
+ removal=dict(turbidity=0.4, tss=0.5, oil_grease=0.85,
37
+ cod=0.55, toc=0.65),
38
+ inlet_max=dict(turbidity=10, tss=10)),
39
+ T("SF", 2, 0.97, 0.30, 0.9, 0.25,
40
+ removal=dict(turbidity=0.8, tss=0.85),
41
+ inlet_max=dict(turbidity=20, tss=20)),
42
+ T("WAC", 2, 0.975, 0.20, 1.3, 0.9,
43
+ removal=dict(tds=0.05, hardness=0.98),
44
+ inlet_max=dict(turbidity=5, tss=5)),
45
+ T("UF", 2, 0.90, 0.20, 1.8, 0.7,
46
+ removal=dict(turbidity=0.995, tss=0.999, oil_grease=0.7, fe=0.6, mn=0.3,
47
+ cod=0.25, toc=0.2),
48
+ inlet_max=dict(turbidity=100, tss=100, oil_grease=5)),
49
+ T("RO Pass1", 3, 0.75, 0.35, 3.0, 2.0,
50
+ removal=dict(turbidity=0.95, tss=0.99, oil_grease=0.9, fe=0.98, mn=0.98,
51
+ cod=0.9, toc=0.95, tds=0.92, sio2=0.9, hardness=0.94),
52
+ inlet_max=dict(turbidity=1, tss=1, oil_grease=0.1, fe=0.3, mn=0.1,
53
+ tds=10000)),
54
+ T("SWRO", 3, 0.45, 0.30, 5.0, 4.5,
55
+ removal=dict(turbidity=0.95, tss=0.99, oil_grease=0.9, fe=0.98, mn=0.98,
56
+ cod=0.9, toc=0.95, tds=0.991, sio2=0.99, hardness=0.995),
57
+ inlet_max=dict(turbidity=1, tss=1, oil_grease=0.1, tds=50000)),
58
+ T("RO Pass2", 3, 0.85, 0.30, 2.2, 1.2,
59
+ removal=dict(cod=0.85, toc=0.9, tds=0.95, sio2=0.95, hardness=0.97),
60
+ inlet_max=dict(turbidity=0.5, tss=0.5, tds=2000)),
61
+ T("EDI", 4, 0.90, 0.25, 2.5, 1.5,
62
+ removal=dict(toc=0.5, tds=0.99, sio2=0.95, hardness=0.999),
63
+ inlet_max=dict(turbidity=0.2, fe=0.01, mn=0.01, toc=0.5,
64
+ tds=50, sio2=1, hardness=1)),
65
+ T("NRMB", 4, 1.0, 0.25, 1.4, 2.5,
66
+ removal=dict(tds=0.99, sio2=0.98, hardness=0.999),
67
+ inlet_max=dict(turbidity=0.2, tds=20)),
68
+ T("GUARD", 4, 1.0, 0.05, 0.3, 0.2,
69
+ removal=dict(turbidity=0.95, tss=0.95, fe=0.3)),
70
+ T("UV", 4, 1.0, 0.40, 0.6, 0.4,
71
+ removal=dict(cod=0.1, toc=0.3),
72
+ inlet_max=dict(turbidity=5)),
73
+ ]
74
+
75
+ TECH_BY_NAME = {t.name: t for t in DEFAULT_TECHNOLOGIES}
watertrain/design.py ADDED
@@ -0,0 +1,218 @@
1
+ """Detailed design of a selected flowsheet.
2
+
3
+ Port of DesignSelected / SizeUnit / MotorKW / stream summary in
4
+ modWTP.bas: backward flow balance, per-unit equipment sizing, forward
5
+ composition profile and a full stream table (product path + rejects).
6
+ Instead of Excel shapes, the PFD is rendered as text (Design.pfd_text()).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass
12
+ from typing import Dict, List, Sequence, Tuple
13
+
14
+ from .model import NP, PARAMS, Flowsheet, WaterCase
15
+ from .engine import round_up_01
16
+
17
+ _IEC_MOTORS = (0.37, 0.55, 0.75, 1.1, 1.5, 2.2, 3.0, 4.0, 5.5, 7.5, 11.0,
18
+ 15.0, 18.5, 22.0, 30.0, 37.0, 45.0, 55.0, 75.0, 90.0, 110.0,
19
+ 132.0, 160.0, 200.0, 250.0)
20
+
21
+
22
+ def motor_kw(hydraulic_kw: float) -> float:
23
+ """Next IEC motor size above hydraulic power / 0.72 * 1.1 margin."""
24
+ p = hydraulic_kw / 0.72 * 1.1
25
+ for x in _IEC_MOTORS:
26
+ if x >= p:
27
+ return x
28
+ return 250.0
29
+
30
+
31
+ def _size_unit(name: str, qf: float, qp: float) -> Tuple[str, str, float]:
32
+ """Return (type, key size, pump motor kW or 0)."""
33
+ typ, sz, mkw = "-", "", 0.0
34
+ if name in ("Clarifier", "DAF", "HRSCC"):
35
+ typ = "clarifier / flotation"
36
+ sz = f"area {round(qf / 1.0, 1)} m2 (2x50%)"
37
+ mkw = motor_kw(qf * 2.0 / 36.0)
38
+ elif name in ("ABF", "MMF", "GSF", "ACF", "SF"):
39
+ typ = "pressure filter (N+1)"
40
+ sz = f"area {round(qf / 15.0, 1)} m2"
41
+ mkw = motor_kw((qf / 2.0) * 2.0 / 36.0)
42
+ elif name == "WAC":
43
+ typ = "WAC softener (ion exchange)"
44
+ sz = f"resin {round(qf * 24.0 * 20.0 / 2.0)} L"
45
+ mkw = motor_kw((qf / 2.0) * 2.5 / 36.0)
46
+ elif name == "UF":
47
+ typ = "ultrafiltration (75 m2 modules)"
48
+ sz = f"{math.ceil(qp * 1000.0 / (50.0 * 75.0))} modules"
49
+ mkw = motor_kw((qf / 2.0) * 3.0 / 36.0)
50
+ elif name in ("RO Pass1", "RO Pass2", "SWRO"):
51
+ flux = 14.0 if name == "SWRO" else 27.0
52
+ bar = {"SWRO": 60.0, "RO Pass2": 10.0, "RO Pass1": 15.0}[name]
53
+ el = math.ceil(qp * 1000.0 / (flux * 37.2))
54
+ pv = math.ceil(el / 6.0)
55
+ typ = "reverse osmosis"
56
+ sz = f"{el} elements / {pv} PV @ {bar:g} bar"
57
+ mkw = motor_kw(qf * bar / 36.0)
58
+ elif name == "EDI":
59
+ typ = "electrodeionisation"
60
+ sz = f"{math.ceil(qp / 10.5)} modules"
61
+ elif name == "NRMB":
62
+ typ = "mixed-bed polisher"
63
+ sz = f"resin {round(qf / 50.0, 2)} m3"
64
+ elif name == "GUARD":
65
+ typ, sz = "guard cartridges 0.22/0.05um", "absolute barrier"
66
+ elif name == "UV":
67
+ typ, sz = "UV 185nm", "N+1 reactors"
68
+ return typ, sz, mkw
69
+
70
+
71
+ def _reject_tag(name: str) -> str:
72
+ if name in ("Clarifier", "HRSCC"):
73
+ return "sludge"
74
+ if name == "DAF":
75
+ return "float"
76
+ if name in ("ABF", "MMF", "GSF", "ACF", "SF", "UF"):
77
+ return "backwash"
78
+ if name == "WAC":
79
+ return "regenerant"
80
+ if name in ("RO Pass1", "RO Pass2", "SWRO"):
81
+ return "concentrate"
82
+ if name in ("EDI", "NRMB"):
83
+ return "reject"
84
+ return "waste"
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Equipment:
89
+ unit: str
90
+ flow_in: float
91
+ flow_out: float
92
+ recovery_pct: float
93
+ type: str
94
+ size: str
95
+ motor_kw: float
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Stream:
100
+ name: str # S1..Sn product path, R1..Rn rejects
101
+ src: str
102
+ dst: str
103
+ flow: float # m3/h
104
+ quality: Tuple[float, ...] # one value per PARAMS entry
105
+
106
+ def quality_dict(self) -> Dict[str, float]:
107
+ return dict(zip(PARAMS, self.quality))
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class Design:
112
+ flowsheet: Flowsheet
113
+ case: WaterCase
114
+ equipment: Tuple[Equipment, ...]
115
+ streams: Tuple[Stream, ...]
116
+
117
+ # ---- reports ---------------------------------------------------------
118
+ def pfd_text(self) -> str:
119
+ """One-line text PFD: FEED -> units (in->out) -> PRODUCT, rejects below."""
120
+ fs = self.flowsheet
121
+ top = [f"FEED {fs.intake:.1f}"]
122
+ rejects = []
123
+ for e in self.equipment:
124
+ top.append(f"[{e.unit} {e.flow_in:.1f}->{e.flow_out:.1f}]")
125
+ r = e.flow_in - e.flow_out
126
+ if r > 1e-3:
127
+ rejects.append(f" {e.unit}: {_reject_tag(e.unit)} "
128
+ f"{r:.1f} m3/h")
129
+ top.append(f"PRODUCT {self.case.product_flow:.1f} m3/h")
130
+ out = " -> ".join(top)
131
+ if rejects:
132
+ out += "\nReject streams:\n" + "\n".join(rejects)
133
+ return out
134
+
135
+ def report(self) -> str:
136
+ fs = self.flowsheet
137
+ L: List[str] = []
138
+ L.append(f"Flowsheet : {fs.sfiles}")
139
+ L.append(f"Intake : {fs.intake:.2f} m3/h "
140
+ f"Recovery: {fs.recovery_pct:.1f}% "
141
+ f"Reliability: {fs.reliability_pct:.1f}% "
142
+ f"TAC: {fs.tac:.3f}")
143
+ L.append("")
144
+ L.append("Equipment schedule")
145
+ hdr = (f"{'Unit':10} {'In m3/h':>8} {'Out m3/h':>9} {'Rec %':>6} "
146
+ f"{'Type':30} {'Key size':28} {'Motor':>8}")
147
+ L.append(hdr)
148
+ L.append("-" * len(hdr))
149
+ for e in self.equipment:
150
+ L.append(f"{e.unit:10} {e.flow_in:8.1f} {e.flow_out:9.1f} "
151
+ f"{e.recovery_pct:6.1f} {e.type:30} {e.size:28} "
152
+ f"{(str(e.motor_kw) + ' kW') if e.motor_kw else '':>8}")
153
+ L.append("")
154
+ L.append("Process Flow Diagram")
155
+ L.append(self.pfd_text())
156
+ L.append("")
157
+ L.append("Stream summary (flow + quality; product path and rejects)")
158
+ head = f"{'Stream':7} {'From':10} {'To':12} {'m3/h':>8} " + \
159
+ " ".join(f"{p:>9}" for p in PARAMS)
160
+ L.append(head)
161
+ L.append("-" * len(head))
162
+ for s in self.streams:
163
+ L.append(f"{s.name:7} {s.src:10} {s.dst:12} {s.flow:8.2f} " +
164
+ " ".join(f"{v:9.4f}" for v in s.quality))
165
+ return "\n".join(L)
166
+
167
+
168
+ def design(flowsheet: Flowsheet, case: WaterCase) -> Design:
169
+ """Size every unit of the flowsheet and build the stream table."""
170
+ seq = flowsheet.techs
171
+ m = len(seq)
172
+ if m == 0:
173
+ raise ValueError("Flowsheet has no units.")
174
+
175
+ # backward flow balance
176
+ fout = [0.0] * m
177
+ fin = [0.0] * m
178
+ fl = round_up_01(case.product_flow)
179
+ for k in range(m - 1, -1, -1):
180
+ fout[k] = fl
181
+ fl = round_up_01(fl / seq[k].recovery)
182
+ fin[k] = fl
183
+ intake = fin[0]
184
+
185
+ # equipment schedule
186
+ equipment = []
187
+ for k, t in enumerate(seq):
188
+ typ, sz, mkw = _size_unit(t.name, fin[k], fout[k])
189
+ equipment.append(Equipment(
190
+ unit=t.name, flow_in=round(fin[k], 1), flow_out=round(fout[k], 1),
191
+ recovery_pct=round(fout[k] / fin[k] * 100.0, 1),
192
+ type=typ, size=sz, motor_kw=mkw))
193
+
194
+ # forward composition profile
195
+ qmat = [list(case.feed)]
196
+ for t in seq:
197
+ qmat.append([qmat[-1][p] * (1.0 - t.removal[p]) for p in range(NP)])
198
+
199
+ # stream table: S1 feed, S2..Sm+1 product path, Rk rejects
200
+ streams: List[Stream] = [
201
+ Stream("S1", "intake", seq[0].name, round(intake, 2),
202
+ tuple(round(v, 4) for v in qmat[0]))]
203
+ for k in range(m):
204
+ dst = seq[k + 1].name if k < m - 1 else "PRODUCT"
205
+ streams.append(Stream(f"S{k + 2}", seq[k].name, dst,
206
+ round(fout[k], 2),
207
+ tuple(round(v, 4) for v in qmat[k + 1])))
208
+ rej = fin[k] - fout[k]
209
+ if rej > 1e-3:
210
+ qr = tuple(round(max(
211
+ (fin[k] * qmat[k][p] - fout[k] * qmat[k + 1][p]) / rej, 0.0), 4)
212
+ for p in range(NP))
213
+ streams.append(Stream(f"R{k + 1}", seq[k].name,
214
+ _reject_tag(seq[k].name),
215
+ round(rej, 2), qr))
216
+
217
+ return Design(flowsheet=flowsheet, case=case,
218
+ equipment=tuple(equipment), streams=tuple(streams))
watertrain/engine.py ADDED
@@ -0,0 +1,180 @@
1
+ """Flowsheet generation and ranking engine.
2
+
3
+ Superstructure depth-first enumeration with pruning rules P1..P7; each
4
+ feasible train is solved by a backward flow balance, an analytical
5
+ reliability (log-normal / normal-in-log-space approximation) and a
6
+ total-annualised-cost (TAC) screening index.
7
+
8
+ Direct port of GenerateFlowsheets / Dfs / Allowed / RecordTrain /
9
+ RankFlowsheets in modWTP.bas — same numbers, same order.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from typing import List, Optional, Sequence
15
+
16
+ from .model import (CAPITAL_CHARGE, HOURS_PER_YEAR, NP, RAW_COST, REJECT_COST,
17
+ Flowsheet, Technology, WaterCase)
18
+ from .database import DEFAULT_TECHNOLOGIES
19
+ from .sfiles import sfiles_string
20
+
21
+ EPS = 1e-9
22
+ TDS = 7 # index of 'tds' in PARAMS
23
+
24
+
25
+ def _phi(z: float) -> float:
26
+ """Standard normal CDF (VBA's Application.NormSDist)."""
27
+ return 0.5 * (1.0 + math.erf(z / math.sqrt(2.0)))
28
+
29
+
30
+ def round_up_01(x: float) -> float:
31
+ """Round a flow up to 0.1 m3/h (VBA RoundUp01)."""
32
+ return math.ceil(x * 10.0 - EPS) / 10.0
33
+
34
+
35
+ def _meets_spec(q: Sequence[float], target: Sequence[float]) -> bool:
36
+ return all(q[p] <= target[p] + EPS for p in range(NP))
37
+
38
+
39
+ def _allowed(train: List[int], tech_i: int, q: Sequence[float],
40
+ techs: Sequence[Technology], case: WaterCase) -> bool:
41
+ """Pruning rules P1..P7: may technology tech_i extend this train?"""
42
+ t = techs[tech_i]
43
+
44
+ # P4 - no duplicate technology
45
+ if tech_i in train:
46
+ return False
47
+
48
+ # P1 - stage monotonicity
49
+ if train and t.stage < techs[train[-1]].stage:
50
+ return False
51
+
52
+ # P2 - inlet feasibility
53
+ for p in range(NP):
54
+ if q[p] > t.inlet_max[p] + EPS:
55
+ return False
56
+
57
+ # P3 - usefulness: must attack an outstanding difference
58
+ useful = any(
59
+ q[p] > case.target[p] + EPS and case.feed[p] > 0 and t.removal[p] > 0.05
60
+ for p in range(NP))
61
+ if not useful:
62
+ return False
63
+
64
+ # P6 - domain triggers
65
+ nm = t.name
66
+ if nm in ("EDI", "NRMB", "RO Pass2"):
67
+ if not any(techs[k].stage == 3 for k in train):
68
+ return False
69
+ if nm == "SWRO" and case.feed[TDS] < 10000:
70
+ return False
71
+ if nm == "RO Pass1" and case.feed[TDS] >= 10000:
72
+ return False
73
+ if nm == "GSF" and (q[3] + q[4]) < 0.1 and q[0] < 1: # Fe+Mn, turbidity
74
+ return False
75
+
76
+ # P7 - first-pass RO needs a filtration barrier immediately upstream
77
+ if nm in ("RO Pass1", "SWRO"):
78
+ if not train or techs[train[-1]].stage != 2:
79
+ return False
80
+
81
+ return True
82
+
83
+
84
+ def _make_flowsheet(train: List[int], q: Sequence[float],
85
+ techs: Sequence[Technology],
86
+ case: WaterCase) -> Flowsheet:
87
+ seq = [techs[k] for k in train]
88
+ depth = len(seq)
89
+
90
+ # backward flow balance (round each stream up to 0.1 m3/h)
91
+ fout = [0.0] * depth
92
+ fin = [0.0] * depth
93
+ fl = round_up_01(case.product_flow)
94
+ for k in range(depth - 1, -1, -1):
95
+ fout[k] = fl
96
+ fl = round_up_01(fl / seq[k].recovery)
97
+ fin[k] = fl
98
+ intake = fin[0]
99
+
100
+ # analytical reliability = min over attributes of P(quality <= spec)
101
+ rel = 1.0
102
+ for p in range(NP):
103
+ if case.feed[p] > 0 and case.target[p] > 0:
104
+ mu = math.log(case.feed[p])
105
+ var = 0.0
106
+ for t in seq:
107
+ e = min(t.removal[p], 0.9999)
108
+ s2 = math.log(1.0 + t.leak_cv ** 2)
109
+ mu += math.log(1.0 - e) - 0.5 * s2
110
+ var += s2
111
+ if var < EPS:
112
+ rp = 1.0 if q[p] <= case.target[p] else 0.0
113
+ else:
114
+ rp = _phi((math.log(case.target[p]) - mu) / math.sqrt(var))
115
+ rel = min(rel, rp)
116
+
117
+ # TAC screening index
118
+ capex = opex = rej = 0.0
119
+ for k, t in enumerate(seq):
120
+ capex += t.capex * fin[k] * 10000.0
121
+ opex += t.opex * fout[k] * 0.05
122
+ rej += fin[k] - fout[k]
123
+ tac = (CAPITAL_CHARGE * capex + HOURS_PER_YEAR * opex
124
+ + HOURS_PER_YEAR * RAW_COST * intake
125
+ + HOURS_PER_YEAR * REJECT_COST * rej)
126
+
127
+ return Flowsheet(
128
+ techs=tuple(seq),
129
+ sfiles=sfiles_string([t.name for t in seq]),
130
+ intake=round(intake, 2),
131
+ recovery_pct=round(case.product_flow / intake * 100.0, 1),
132
+ reliability_pct=round(rel * 100.0, 1),
133
+ tac=round(tac / 1e6, 3),
134
+ quality_out=tuple(q),
135
+ )
136
+
137
+
138
+ def generate(case: WaterCase,
139
+ technologies: Optional[Sequence[Technology]] = None
140
+ ) -> List[Flowsheet]:
141
+ """Enumerate every feasible treatment train for the case.
142
+
143
+ Returns flowsheets in discovery (DFS) order, capped at
144
+ case.max_flowsheets and case.max_train_len.
145
+ """
146
+ case.validate()
147
+ techs = list(technologies if technologies is not None
148
+ else DEFAULT_TECHNOLOGIES)
149
+ if not techs:
150
+ raise ValueError("Technology database is empty.")
151
+
152
+ out: List[Flowsheet] = []
153
+
154
+ def dfs(train: List[int], q: List[float]) -> None:
155
+ if len(out) >= case.max_flowsheets:
156
+ return
157
+ # goal: product spec met -> record and stop (no gold-plating)
158
+ if train and _meets_spec(q, case.target):
159
+ out.append(_make_flowsheet(train, q, techs, case))
160
+ return
161
+ if len(train) >= case.max_train_len:
162
+ return
163
+ for i in range(len(techs)):
164
+ if _allowed(train, i, q, techs, case):
165
+ nq = [q[p] * (1.0 - techs[i].removal[p]) for p in range(NP)]
166
+ train.append(i)
167
+ dfs(train, nq)
168
+ train.pop()
169
+ if len(out) >= case.max_flowsheets:
170
+ return
171
+
172
+ dfs([], list(case.feed))
173
+ return out
174
+
175
+
176
+ def rank(flowsheets: Sequence[Flowsheet], top_x: int = 10) -> List[Flowsheet]:
177
+ """Return the top_x flowsheets sorted ascending by TAC (stable)."""
178
+ if top_x <= 0:
179
+ top_x = 10
180
+ return sorted(flowsheets, key=lambda f: f.tac)[:top_x]
watertrain/examples.py ADDED
@@ -0,0 +1,51 @@
1
+ """The 7 built-in example cases (sheet 'Examples' of the workbook)."""
2
+ from .model import WaterCase
3
+
4
+ def _ex(name, feed, target, flow, source):
5
+ keys = ("turbidity", "tss", "oil_grease", "fe", "mn",
6
+ "cod", "toc", "tds", "sio2", "hardness")
7
+ return WaterCase.create(feed=dict(zip(keys, feed)),
8
+ target=dict(zip(keys, target)),
9
+ product_flow=flow, source=source,
10
+ max_train_len=9, max_flowsheets=200, name=name)
11
+
12
+ EXAMPLES = {e.name: e for e in [
13
+ _ex("1. CTBD reclaim (Ullah 2023)",
14
+ (17.1, 38.4, 0.5, 0.3, 0.1, 30, 8, 1484.8, 68.4, 1230),
15
+ (1, 1, 0.1, 0.1, 0.05, 10, 3, 300, 25, 270),
16
+ 9, "Treated Wastewater/Sewage"),
17
+ _ex("2. SWRO desalination",
18
+ (15, 25, 0.5, 0.05, 0.02, 8, 3, 40000, 1, 6500),
19
+ (0.5, 1, 0.05, 0.2, 0.05, 5, 2, 450, 1, 80),
20
+ 600, "Open Intake"),
21
+ _ex("3. TSE reuse to process water",
22
+ (8, 15, 2, 0.3, 0.1, 45, 12, 1200, 18, 250),
23
+ (1, 1, 0.1, 0.1, 0.05, 10, 2, 100, 5, 50),
24
+ 500, "Treated Wastewater/Sewage"),
25
+ _ex("4. Ultra-pure water",
26
+ (5, 2, 0.2, 0.2, 0.1, 15, 10, 300, 10, 146),
27
+ (0.05, 0.05, 0.02, 0.005, 0.005, 0.5, 0.02, 0.06, 0.05, 0.05),
28
+ 152, "Municipal"),
29
+ _ex("5. PFD-6 seawater desalination",
30
+ (12, 20, 0.3, 0.05, 0.02, 6, 2.5, 45000, 1, 6500),
31
+ (0.5, 1, 0.05, 0.2, 0.05, 5, 2, 500, 1, 100),
32
+ 500, "Open Intake"),
33
+ _ex("6. PFD-1 reject reclaim",
34
+ (10, 15, 1, 0.3, 0.1, 40, 10, 5384, 30, 193),
35
+ (1, 1, 0.1, 0.1, 0.05, 10, 2, 100, 5, 20),
36
+ 19.3, "Treated Wastewater/Sewage"),
37
+ _ex("7. PFD-2 HRSCC reclaim",
38
+ (12, 18, 1, 0.4, 0.15, 50, 12, 9735, 40, 300),
39
+ (1, 1, 0.1, 0.1, 0.05, 10, 2, 150, 8, 30),
40
+ 17.9, "Treated Wastewater/Sewage"),
41
+ ]}
42
+
43
+ def get_example(key) -> WaterCase:
44
+ """Look up an example by full name or by its leading number (1-7)."""
45
+ if key in EXAMPLES:
46
+ return EXAMPLES[key]
47
+ s = str(key).strip()
48
+ for name, case in EXAMPLES.items():
49
+ if name.split(".")[0] == s:
50
+ return case
51
+ raise KeyError(f"No example '{key}'. Available: {list(EXAMPLES)}")
watertrain/excel.py ADDED
@@ -0,0 +1,88 @@
1
+ """Optional Excel bridge (requires openpyxl: pip install watertrain[excel]).
2
+
3
+ Lets users keep maintaining the technology database and inputs in the
4
+ original workbook (WTP_FlowsheetGenerator.xlsm) and run the Python engine
5
+ against it — same cell layout as the VBA ReadDatabase / ReadInputs.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import List
10
+
11
+ from .model import NP, Technology, WaterCase, PARAMS
12
+
13
+ try:
14
+ from openpyxl import load_workbook
15
+ except ImportError as _e: # pragma: no cover
16
+ load_workbook = None
17
+ _import_error = _e
18
+
19
+
20
+ def _require_openpyxl():
21
+ if load_workbook is None:
22
+ raise ImportError(
23
+ "openpyxl is required for Excel loading. "
24
+ "Install with: pip install watertrain[excel]") from _import_error
25
+
26
+
27
+ def _num(v, default=0.0) -> float:
28
+ """Locale-safe numeric read (VBA NumVal)."""
29
+ if v is None:
30
+ return default
31
+ if isinstance(v, str):
32
+ v = v.strip().replace(",", ".")
33
+ if not v:
34
+ return default
35
+ try:
36
+ return float(v)
37
+ except ValueError:
38
+ return default
39
+ try:
40
+ return float(v)
41
+ except (TypeError, ValueError):
42
+ return default
43
+
44
+
45
+ def load_technologies(path: str,
46
+ sheet: str = "2.ProcessGroups") -> List[Technology]:
47
+ """Read the technology database from the workbook (rows 4+)."""
48
+ _require_openpyxl()
49
+ wb = load_workbook(path, data_only=True, read_only=True, keep_vba=False)
50
+ ws = wb[sheet]
51
+ techs: List[Technology] = []
52
+ for row in ws.iter_rows(min_row=4, values_only=True):
53
+ if not row or row[0] in (None, ""):
54
+ break
55
+ name = str(row[0])
56
+ removal = {PARAMS[p]: _num(row[6 + p]) for p in range(NP)}
57
+ inlet = {}
58
+ for p in range(NP):
59
+ v = row[16 + p] if len(row) > 16 + p else None
60
+ if v not in (None, ""):
61
+ inlet[PARAMS[p]] = _num(v)
62
+ techs.append(Technology.create(
63
+ name=name, stage=int(_num(row[1])),
64
+ recovery=_num(row[2]), leak_cv=_num(row[3]),
65
+ capex=_num(row[4]), opex=_num(row[5]),
66
+ removal=removal, inlet_max=inlet))
67
+ wb.close()
68
+ return techs
69
+
70
+
71
+ def load_case(path: str, sheet: str = "1.WaterQuality") -> WaterCase:
72
+ """Read the current inputs from the workbook (feed, spec, flow...)."""
73
+ _require_openpyxl()
74
+ wb = load_workbook(path, data_only=True, read_only=True, keep_vba=False)
75
+ ws = wb[sheet]
76
+ feed, target = {}, {}
77
+ for p in range(NP):
78
+ r = 4 + p
79
+ feed[PARAMS[p]] = _num(ws.cell(r, 2).value)
80
+ target[PARAMS[p]] = _num(ws.cell(r, 3).value)
81
+ flow = _num(ws.cell(15, 2).value)
82
+ source = str(ws.cell(16, 2).value or "")
83
+ max_len = int(_num(ws.cell(17, 2).value)) or 7
84
+ max_fs = int(_num(ws.cell(18, 2).value)) or 200
85
+ wb.close()
86
+ return WaterCase.create(feed=feed, target=target, product_flow=flow,
87
+ source=source, max_train_len=max_len,
88
+ max_flowsheets=max_fs, name=path)
watertrain/model.py ADDED
@@ -0,0 +1,131 @@
1
+ """Core data model for the WTP flowsheet generator.
2
+
3
+ Direct port of the Excel/VBA tool (modWTP.bas). The 10 water-quality
4
+ attributes and their order are fixed and must match everywhere:
5
+ removals, inlet limits, feed and product-spec vectors.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Dict, List, Optional, Sequence, Tuple
11
+
12
+ #: Water-quality attribute order (index 0..9). Matches 1.WaterQuality rows 4-13.
13
+ PARAMS: Tuple[str, ...] = (
14
+ "turbidity", "tss", "oil_grease", "fe", "mn",
15
+ "cod", "toc", "tds", "sio2", "hardness",
16
+ )
17
+ NP = len(PARAMS)
18
+
19
+ #: "no inlet limit" sentinel (same as 1E30 in the VBA)
20
+ NO_LIMIT = 1e30
21
+
22
+ # Economics constants (screening index, not a bid price)
23
+ CAPITAL_CHARGE = 0.1 # /yr
24
+ HOURS_PER_YEAR = 8000.0
25
+ RAW_COST = 0.3 # $/m3 raw water proxy
26
+ REJECT_COST = 0.5 # $/m3 reject disposal proxy
27
+
28
+
29
+ def _vec(values: Optional[Dict[str, float]], default: float) -> Tuple[float, ...]:
30
+ values = values or {}
31
+ unknown = set(values) - set(PARAMS)
32
+ if unknown:
33
+ raise ValueError(f"Unknown water-quality parameter(s): {sorted(unknown)}. "
34
+ f"Valid: {list(PARAMS)}")
35
+ return tuple(float(values.get(p, default)) for p in PARAMS)
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Technology:
40
+ """One row of the 2.ProcessGroups technology database.
41
+
42
+ removal: fraction removed per attribute (0-1)
43
+ inlet_max: max tolerated inlet concentration per attribute (NO_LIMIT = none)
44
+ stage: 1 = pre-treatment, 2 = filtration, 3 = RO, 4 = polish
45
+ """
46
+ name: str
47
+ stage: int
48
+ recovery: float # water recovery fraction (0-1]
49
+ leak_cv: float # performance CV driving reliability spread
50
+ capex: float # relative capex index
51
+ opex: float # relative opex index
52
+ removal: Tuple[float, ...]
53
+ inlet_max: Tuple[float, ...]
54
+
55
+ @classmethod
56
+ def create(cls, name: str, stage: int, recovery: float, leak_cv: float,
57
+ capex: float, opex: float,
58
+ removal: Optional[Dict[str, float]] = None,
59
+ inlet_max: Optional[Dict[str, float]] = None) -> "Technology":
60
+ rec = float(recovery) if recovery and recovery > 0 else 1.0 # guard /0
61
+ return cls(name=name, stage=int(stage), recovery=rec,
62
+ leak_cv=float(leak_cv), capex=float(capex), opex=float(opex),
63
+ removal=_vec(removal, 0.0),
64
+ inlet_max=_vec(inlet_max, NO_LIMIT))
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class WaterCase:
69
+ """A design case: raw-water quality, product spec, product flow.
70
+
71
+ feed / target are given as {parameter: value} dicts; missing
72
+ parameters default to 0 (feed) which means "not present".
73
+ """
74
+ feed: Tuple[float, ...]
75
+ target: Tuple[float, ...]
76
+ product_flow: float # m3/h
77
+ source: str = ""
78
+ max_train_len: int = 7
79
+ max_flowsheets: int = 200
80
+ name: str = ""
81
+
82
+ @classmethod
83
+ def create(cls, feed: Dict[str, float], target: Dict[str, float],
84
+ product_flow: float, source: str = "",
85
+ max_train_len: int = 7, max_flowsheets: int = 200,
86
+ name: str = "") -> "WaterCase":
87
+ case = cls(feed=_vec(feed, 0.0), target=_vec(target, 0.0),
88
+ product_flow=float(product_flow), source=source,
89
+ max_train_len=int(max_train_len),
90
+ max_flowsheets=int(max_flowsheets), name=name)
91
+ case.validate()
92
+ return case
93
+
94
+ def validate(self) -> None:
95
+ if all(f <= 0 for f in self.feed):
96
+ raise ValueError("All feed values are zero/blank - nothing to treat.")
97
+ blanks = [PARAMS[p] for p in range(NP)
98
+ if self.feed[p] > 0 and self.target[p] <= 0]
99
+ if blanks:
100
+ raise ValueError(
101
+ "Product spec is blank/zero for parameters present in the feed "
102
+ f"(a spec of 0 can never be met): {blanks}")
103
+ if self.product_flow <= 0:
104
+ raise ValueError("product_flow must be > 0 (m3/h).")
105
+
106
+ def feed_dict(self) -> Dict[str, float]:
107
+ return dict(zip(PARAMS, self.feed))
108
+
109
+ def target_dict(self) -> Dict[str, float]:
110
+ return dict(zip(PARAMS, self.target))
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class Flowsheet:
115
+ """One feasible treatment train with its screening metrics."""
116
+ techs: Tuple[Technology, ...]
117
+ sfiles: str
118
+ intake: float # m3/h at battery limit
119
+ recovery_pct: float # product / intake * 100
120
+ reliability_pct: float # P(product meets spec) * 100
121
+ tac: float # total annualised cost, screening index (M$/yr scale)
122
+ quality_out: Tuple[float, ...] # deterministic product quality
123
+
124
+ @property
125
+ def unit_names(self) -> List[str]:
126
+ return [t.name for t in self.techs]
127
+
128
+ def __str__(self) -> str:
129
+ return (f"{self.sfiles} intake={self.intake:.2f} m3/h "
130
+ f"rec={self.recovery_pct:.1f}% rel={self.reliability_pct:.1f}% "
131
+ f"TAC={self.tac:.3f}")
watertrain/sfiles.py ADDED
@@ -0,0 +1,15 @@
1
+ """SFILES-style string representation of a treatment train."""
2
+ from typing import Iterable
3
+
4
+ _TAGS = {
5
+ "Clarifier": "clar", "DAF": "daf", "HRSCC": "hrscc", "ABF": "abf",
6
+ "MMF": "mmf", "GSF": "gsf", "ACF": "acf", "SF": "sf", "WAC": "wac",
7
+ "UF": "uf", "RO Pass1": "rop1", "SWRO": "swro", "RO Pass2": "rop2",
8
+ "EDI": "edi", "NRMB": "nrmb", "GUARD": "guard", "UV": "uv",
9
+ }
10
+
11
+ def sfiles_tag(name: str) -> str:
12
+ return _TAGS.get(name, name.lower())
13
+
14
+ def sfiles_string(unit_names: Iterable[str]) -> str:
15
+ return "(iF)" + "".join(f"({sfiles_tag(n)})" for n in unit_names) + "(oP)"
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: watertrain
3
+ Version: 1.0.0
4
+ Summary: Automatic water-treatment-plant flowsheet generator: superstructure enumeration with pruning, analytical reliability and TAC ranking, plus equipment sizing.
5
+ Author: Anjan Tula
6
+ License: MIT
7
+ Keywords: water treatment,flowsheet,process synthesis,superstructure,reverse osmosis,desalination
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ Provides-Extra: excel
14
+ Requires-Dist: openpyxl>=3.1; extra == "excel"
15
+
16
+ # watertrain — Automatic WTP Flowsheet Generator
17
+
18
+ Python port of the Excel/VBA tool `WTP_FlowsheetGenerator.xlsm`. Generates
19
+ every feasible water-treatment train for a given raw-water quality and
20
+ product spec, ranks them by a total-annualised-cost (TAC) screening index,
21
+ and sizes the equipment of any selected train.
22
+
23
+ **Method:** superstructure depth-first enumeration with pruning rules P1–P7,
24
+ backward flow / forward composition balance, analytical reliability
25
+ (log-normal approximation driven by the leak-CV column) and a TAC index.
26
+ The Python engine reproduces the VBA workbook results exactly (see
27
+ `tests/test_parity.py`).
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install . # core (no dependencies, pure Python >= 3.9)
33
+ pip install .[excel] # + openpyxl, to read cases/DB from the workbook
34
+ ```
35
+
36
+ ## Quick start (Python API)
37
+
38
+ ```python
39
+ from watertrain import WaterCase, generate, rank, design
40
+
41
+ case = WaterCase.create(
42
+ feed=dict(turbidity=10, tss=15, oil_grease=1, fe=0.3, mn=0.1,
43
+ cod=40, toc=10, tds=5384, sio2=30, hardness=193),
44
+ target=dict(turbidity=1, tss=1, oil_grease=0.1, fe=0.1, mn=0.05,
45
+ cod=10, toc=2, tds=100, sio2=5, hardness=20),
46
+ product_flow=19.3, # m3/h
47
+ source="Treated Wastewater/Sewage",
48
+ max_train_len=9, max_flowsheets=200)
49
+
50
+ flowsheets = generate(case) # 1. all feasible trains
51
+ best = rank(flowsheets, top_x=5) # 2. cheapest first (TAC)
52
+ d = design(best[0], case) # 3. size the chosen train
53
+ print(d.report()) # equipment schedule + PFD + stream table
54
+ ```
55
+
56
+ The workflow mirrors the workbook macros:
57
+ `GenerateFlowsheets → RankFlowsheets → DesignSelected`.
58
+
59
+ Built-in example cases (same 7 as the workbook):
60
+
61
+ ```python
62
+ from watertrain import get_example
63
+ case = get_example(6) # "6. PFD-1 reject reclaim"
64
+ ```
65
+
66
+ ## Command line
67
+
68
+ ```bash
69
+ watertrain --list-examples
70
+ watertrain --example 6 --top 5 --design 1
71
+ watertrain --case mycase.json --top 10
72
+ ```
73
+
74
+ `mycase.json`:
75
+
76
+ ```json
77
+ {
78
+ "feed": {"turbidity": 10, "tss": 15, "tds": 5384, "hardness": 193},
79
+ "target": {"turbidity": 1, "tss": 1, "tds": 100, "hardness": 20},
80
+ "product_flow": 19.3,
81
+ "max_train_len": 9
82
+ }
83
+ ```
84
+
85
+ Parameters omitted from `feed` default to 0 (not present).
86
+
87
+ ## Custom technology database
88
+
89
+ The default database (`watertrain.DEFAULT_TECHNOLOGIES`) is the
90
+ `2.ProcessGroups` sheet. Pass your own list to `generate()`:
91
+
92
+ ```python
93
+ from watertrain import Technology, generate
94
+
95
+ my_tech = Technology.create(
96
+ "MyMBR", stage=2, recovery=0.95, leak_cv=0.25, capex=2.0, opex=0.9,
97
+ removal=dict(turbidity=0.99, tss=0.999, cod=0.6, toc=0.5),
98
+ inlet_max=dict(turbidity=200))
99
+
100
+ techs = list(__import__("watertrain").DEFAULT_TECHNOLOGIES) + [my_tech]
101
+ flowsheets = generate(case, technologies=techs)
102
+ ```
103
+
104
+ Or keep editing the database in Excel and load it live
105
+ (requires `pip install .[excel]`):
106
+
107
+ ```python
108
+ from watertrain.excel import load_case, load_technologies
109
+ case = load_case("WTP_FlowsheetGenerator.xlsm")
110
+ techs = load_technologies("WTP_FlowsheetGenerator.xlsm")
111
+ ```
112
+
113
+ ## Water-quality parameters (fixed order)
114
+
115
+ `turbidity, tss, oil_grease, fe, mn, cod, toc, tds, sio2, hardness`
116
+
117
+ ## Notes
118
+
119
+ * Reliability is P(product meets spec) under performance uncertainty.
120
+ * TAC is a screening cost index for ranking, not a bid price.
121
+ * Run the parity tests with `pytest`.
122
+
123
+ ## Package layout
124
+
125
+ ```
126
+ src/watertrain/
127
+ model.py dataclasses: Technology, WaterCase, Flowsheet; constants
128
+ database.py default technology database (2.ProcessGroups)
129
+ examples.py the 7 example cases
130
+ engine.py generate() + rank(): DFS, pruning P1–P7, reliability, TAC
131
+ design.py design(): flow balance, equipment sizing, streams, text PFD
132
+ sfiles.py SFILES string helpers
133
+ excel.py optional openpyxl bridge to the original workbook
134
+ cli.py `watertrain` command
135
+ tests/test_parity.py reproduces the workbook numbers exactly
136
+ ```
@@ -0,0 +1,14 @@
1
+ watertrain/__init__.py,sha256=HFXtGRlhqe-7HA83rC3DmGR--EByTVGYKDzi1IOWEPo,1563
2
+ watertrain/cli.py,sha256=pLT6H3MfrA-NhAnukt4m5iZfwZg3c8ftsvEe7LePOgc,3717
3
+ watertrain/database.py,sha256=1O-z4SqBjGfWPl8ASb7m0FqNXIU8-DppC6qcz9mu0po,3509
4
+ watertrain/design.py,sha256=E0tcQKoJm7G0hcMFvdZ4VjynP0oCZUf0e6EynH4jzpg,7871
5
+ watertrain/engine.py,sha256=gJsI22CcLtAyaVH-B4oSX0di1qHKLUXjkb3_4m32l3E,5949
6
+ watertrain/examples.py,sha256=9y1oOtJU8tJlt1ab9oIA5LzwIbGUhTlfMB7yN4tlGxY,2196
7
+ watertrain/excel.py,sha256=D2n6aPJig2K1wUiv0JcOp1-gHgb6SaAHE2hx9Pgn-HE,3040
8
+ watertrain/model.py,sha256=UH9xNm9FMVDyvYUYyltNH4l2rYjcW79smSj2Ar4tsO4,5032
9
+ watertrain/sfiles.py,sha256=-tLaCDsCIzFAc_q-damIiiHvskRt1rb057dV4xj__C4,592
10
+ watertrain-1.0.0.dist-info/METADATA,sha256=Ym3ldzRnJCDzAlywxZObgWo1jISXGuTfP9lxbObKQPc,4702
11
+ watertrain-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
12
+ watertrain-1.0.0.dist-info/entry_points.txt,sha256=TUsMtx0_t5aSdDFnoO7p5Q7NDauZVhWvholA6xO2kOQ,51
13
+ watertrain-1.0.0.dist-info/top_level.txt,sha256=XcNd979HWJH-1Ms0lm5zDifBxCaTZlyjw7Nu4CwiEg0,11
14
+ watertrain-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ watertrain = watertrain.cli:main
@@ -0,0 +1 @@
1
+ watertrain