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 +42 -0
- watertrain/cli.py +107 -0
- watertrain/database.py +75 -0
- watertrain/design.py +218 -0
- watertrain/engine.py +180 -0
- watertrain/examples.py +51 -0
- watertrain/excel.py +88 -0
- watertrain/model.py +131 -0
- watertrain/sfiles.py +15 -0
- watertrain-1.0.0.dist-info/METADATA +136 -0
- watertrain-1.0.0.dist-info/RECORD +14 -0
- watertrain-1.0.0.dist-info/WHEEL +5 -0
- watertrain-1.0.0.dist-info/entry_points.txt +2 -0
- watertrain-1.0.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
watertrain
|