simjsr 0.0.2__tar.gz → 0.0.4__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.
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: simjsr
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Author: Andreas V. Copan
5
5
  Author-email: Andreas V. Copan <avcopan@uga.edu>
6
6
  Requires-Dist: cantera>=3.2.0
7
+ Requires-Dist: func-timeout>=4.3.5
7
8
  Requires-Dist: polars>=1.41.2
8
9
  Requires-Dist: pydantic>=2.13.4
9
10
  Requires-Dist: pyyaml>=6.0.3
@@ -1,10 +1,11 @@
1
1
  [project]
2
2
  name = "simjsr"
3
- version = "0.0.2"
3
+ version = "0.0.4"
4
4
  authors = [{name = "Andreas V. Copan", email = "avcopan@uga.edu"}]
5
5
  requires-python = ">= 3.13"
6
6
  dependencies = [
7
7
  "cantera>=3.2.0",
8
+ "func-timeout>=4.3.5",
8
9
  "polars>=1.41.2",
9
10
  "pydantic>=2.13.4",
10
11
  "pyyaml>=6.0.3",
@@ -1,6 +1,6 @@
1
1
  """simrun."""
2
2
 
3
- __version__ = "0.0.2"
3
+ __version__ = "0.0.4"
4
4
 
5
5
  from . import config, convert, run
6
6
  from .config import Config
@@ -48,11 +48,8 @@ def run(
48
48
  ] = False,
49
49
  ) -> None:
50
50
  """Run a JSR simulation workflow."""
51
- typer.echo("Loading config file\n")
52
- configs = Config.multi_from_yaml(file=config_file)
53
-
54
51
  multi(
55
- configs,
52
+ config_file,
56
53
  pass_state=not no_pass_state,
57
54
  output_file=Path(output_file),
58
55
  logger=typer.echo,
@@ -118,9 +115,9 @@ def example(
118
115
  if multi_composition:
119
116
  composition_file = dir_path / File.composition
120
117
  data = {
121
- "CH4": [0.05, 0.1, 0.15],
122
- "O2": [0.21, 0.21, 0.21],
123
- "N2": [0.74, 0.59, 0.44],
118
+ "CH4": [0.05, 0.05, 0.05],
119
+ "O2": [0.21, 0.22, 0.23],
120
+ "N2": [0.74, 0.73, 0.72],
124
121
  }
125
122
  pl.DataFrame(data).write_csv(composition_file)
126
123
  update[Key.composition] = File.composition
@@ -1,7 +1,7 @@
1
1
  """Configuration for simulations."""
2
2
 
3
3
  from pathlib import Path
4
- from typing import Any
4
+ from typing import Any, Self
5
5
 
6
6
  import polars as pl
7
7
  import yaml
@@ -33,7 +33,7 @@ class Config(BaseModel):
33
33
  description="Starting composition (mole fractions)"
34
34
  )
35
35
  volume: float = Field(description="Reactor volume (cm^3)", default=1.0)
36
- time_out: int | None = Field(
36
+ time_out: float | None = Field(
37
37
  description="Time limit for the simulation (s)", default=None
38
38
  )
39
39
  chemkin_file: Path | None = Field(
@@ -101,19 +101,21 @@ class Config(BaseModel):
101
101
  @classmethod
102
102
  def from_yaml(
103
103
  cls, *, file: str | Path | None = None, text: str | Path | None = None
104
- ) -> "Config":
104
+ ) -> Self:
105
105
  """Instantiate from YAML file or text."""
106
- config, *extra_configs = cls.multi_from_yaml(file=file, text=text)
106
+ (config, *extra_configs), _ = cls.all_with_dataframe_from_yaml(
107
+ file=file, text=text
108
+ )
107
109
  if extra_configs:
108
110
  msg = f"Cannot read config from multi-config YAML. {extra_configs = }"
109
111
  raise ValueError(msg)
110
112
  return config
111
113
 
112
114
  @classmethod
113
- def multi_from_yaml(
115
+ def all_with_dataframe_from_yaml(
114
116
  cls, *, file: str | Path | None = None, text: str | Path | None = None
115
- ) -> "list[Config]":
116
- """Instantiate multiple configs from YAML file or text."""
117
+ ) -> tuple[list[Self], pl.DataFrame | None]:
118
+ """Instantiate from YAML file or text and return as a DataFrame."""
117
119
  if file is not None:
118
120
  text = Path(file).read_text()
119
121
 
@@ -122,8 +124,6 @@ class Config(BaseModel):
122
124
  raise ValueError(msg)
123
125
 
124
126
  data = yaml.safe_load(text)
125
-
126
- datas = [data]
127
127
  temperature = data.get(Key.temperature)
128
128
  composition = data.get(Key.composition)
129
129
 
@@ -131,32 +131,18 @@ class Config(BaseModel):
131
131
  msg = f"{temperature = } and {composition = } cannot both be file paths."
132
132
  raise TypeError(msg)
133
133
 
134
+ df = None
135
+ datas = [data]
134
136
  if isinstance(temperature, str):
135
- ts = _load_temperatures_from_file(temperature)
136
- datas = [{**data, "temperature": t} for t in ts]
137
+ df = pl.read_csv(temperature)
138
+ datas = [{**data, Key.temperature: t} for t in df[:, 0]]
137
139
 
138
140
  if isinstance(composition, str):
139
- cs = _load_compositions_from_file(composition)
140
- datas = [{**data, "composition": c} for c in cs]
141
-
142
- return [cls.model_validate(d) for d in datas]
143
-
144
-
145
- def _to_yaml_serialized_value(value: object) -> str:
146
- """Serialize a value to a YAML string."""
147
- return yaml.safe_dump(value, default_flow_style=True).splitlines()[0]
148
-
149
-
150
- def _load_temperatures_from_file(temps_file: Path | str) -> list[float]:
151
- """Load temperatures (K) from a CSV file with a 'temperature' column."""
152
- df = pl.read_csv(temps_file)
153
- return df.get_column(df.columns[0]).cast(pl.Float64).to_list()
141
+ df = pl.read_csv(composition)
142
+ datas = [{**data, Key.composition: c} for c in df.iter_rows(named=True)]
154
143
 
155
-
156
- def _load_compositions_from_file(comps_file: Path | str) -> list[dict[str, float]]:
157
- """Load compositions from a YAML file with a 'compositions' key."""
158
- df = pl.read_csv(comps_file)
159
- return df.to_dicts()
144
+ configs = [cls.model_validate(d) for d in datas]
145
+ return configs, df
160
146
 
161
147
 
162
148
  class Key:
@@ -164,3 +150,9 @@ class Key:
164
150
 
165
151
  temperature = "temperature"
166
152
  composition = "composition"
153
+
154
+
155
+ # Helpers
156
+ def _to_yaml_serialized_value(value: object) -> str:
157
+ """Serialize a value to a YAML string."""
158
+ return yaml.safe_dump(value, default_flow_style=True).splitlines()[0]
@@ -0,0 +1,221 @@
1
+ """Reactors."""
2
+
3
+ import textwrap
4
+ from collections.abc import Callable, Sequence
5
+ from copy import replace
6
+ from datetime import UTC, datetime, timedelta
7
+ from pathlib import Path
8
+ from time import perf_counter
9
+ from typing import Any, TypeGuard
10
+
11
+ import cantera as ct
12
+ import polars as pl
13
+ from func_timeout import FunctionTimedOut, func_timeout
14
+
15
+ from . import convert
16
+ from .config import Config
17
+
18
+ Logger = Callable[[str], Any]
19
+
20
+
21
+ def multi(
22
+ config_: Sequence[Config] | Path | str,
23
+ *,
24
+ pass_state: bool = True,
25
+ output_file: Path | None = None,
26
+ logger: Logger = lambda _: None,
27
+ ) -> list[dict[str, float]]:
28
+ """Run multiple jet-stirred reactor simulations.
29
+
30
+ Args:
31
+ config_: Configurations for the simulations
32
+ pass_state: Whether to pass solved state to the next simulation, if its
33
+ initial composition is the same
34
+ logger: Optional logger for messages
35
+ output_file: Optional file to save simulation results
36
+
37
+ Returns:
38
+ Steady state mole fractions for each simulation
39
+ """
40
+ df = None
41
+ if isinstance(config_, Sequence) and not isinstance(config_, str):
42
+ configs = config_
43
+ else:
44
+ logger("Loading config file")
45
+
46
+ configs, df = Config.all_with_dataframe_from_yaml(file=config_)
47
+
48
+ if not is_sequence_of(configs, Config):
49
+ msg = "All configurations must be of type Config"
50
+ raise TypeError(msg)
51
+
52
+ config_count = len(configs)
53
+ last_config = last_mole_fracs = None
54
+ mole_fracs_lst = []
55
+ for num, init_config in enumerate(configs, start=1):
56
+ yaml_text = init_config.yaml_text(describe=True)
57
+ logger(f"Run {num} / {config_count}:")
58
+ logger(textwrap.indent(yaml_text.strip(), " "))
59
+
60
+ # If requested, re-use solved composition
61
+ config = init_config
62
+ if (
63
+ pass_state
64
+ and init_config.is_compatible_with(last_config)
65
+ and last_mole_fracs is not None
66
+ ):
67
+ config = replace(init_config, composition=last_mole_fracs)
68
+
69
+ mole_fracs = single(config=config, logger=logger, raise_on_timout=False)
70
+ mole_fracs_lst.append(mole_fracs)
71
+
72
+ last_config = init_config
73
+ last_mole_fracs = mole_fracs
74
+
75
+ logger("")
76
+
77
+ if output_file is not None:
78
+ df_out = pl.from_dicts(mole_fracs_lst)
79
+ if df is not None:
80
+ df_out = hconcat_rename_left(df, df_out, suffix="_init")
81
+
82
+ df_out.write_csv(output_file)
83
+
84
+ return mole_fracs_lst
85
+
86
+
87
+ def single(
88
+ config: Config | Path | str,
89
+ *,
90
+ logger: Logger = lambda _: None,
91
+ output_file: Path | None = None,
92
+ raise_on_timout: bool = True,
93
+ ) -> dict[str, float]:
94
+ """Run a single jet-stirred reactor simulation.
95
+
96
+ Args:
97
+ config: Configuration for the simulation
98
+ logger: Optional logger for messages
99
+ output_file: Optional file to save simulation results
100
+ raise_on_timout: Whether to raise a TimeoutError if the simulation times out
101
+
102
+ Returns:
103
+ Steady state mole fractions
104
+
105
+ Raises:
106
+ TimeoutError: If the simulation exceeds the configured time limit
107
+ """
108
+ start_counter = clock_start(logger, "Starting simulation")
109
+
110
+ config = config if isinstance(config, Config) else Config.from_yaml(file=config)
111
+
112
+ if not config.cantera_file.exists():
113
+ generate_cantera_file_from_chemkin(config, logger=logger)
114
+
115
+ phase = ct.Solution(config.cantera_file)
116
+
117
+ try:
118
+ mole_fracs = (
119
+ _single(config)
120
+ if config.time_out is None
121
+ else func_timeout(config.time_out, _single, (config,))
122
+ )
123
+ except FunctionTimedOut:
124
+ clock_end(logger, "Simulation timed out", start_counter)
125
+ mole_fracs = {s: float("nan") for s in phase.species_names}
126
+ if raise_on_timout:
127
+ raise
128
+ else:
129
+ clock_end(logger, "Finished simulation", start_counter)
130
+
131
+ if output_file is not None:
132
+ pl.from_dicts([mole_fracs]).write_csv(output_file)
133
+
134
+ return mole_fracs
135
+
136
+
137
+ def _single(config: Config) -> dict[str, float]:
138
+ """Run a single jet-stirred reactor simulation (no timeout handling or logging).
139
+
140
+ Args:
141
+ config: Configuration for the simulation
142
+
143
+ Returns:
144
+ Steady state mole fractions
145
+ """
146
+ phase = ct.Solution(config.cantera_file)
147
+
148
+ # Use composition from the previous iteration to speed up convergence
149
+ phase.TPX = config.temperature, config.pressure * ct.one_atm, config.composition
150
+
151
+ # Set up JSR: inlet -> flow control -> reactor -> pressure control -> exhaust
152
+ volume_m3 = config.volume * (1e-2) ** 3
153
+ reactor = ct.IdealGasReactor(phase, energy="off", volume=volume_m3, clone=True)
154
+ exhaust = ct.Reservoir(phase, clone=True)
155
+ inlet = ct.Reservoir(phase, clone=True)
156
+ ct.PressureController(
157
+ upstream=reactor,
158
+ downstream=exhaust,
159
+ K=1e-3,
160
+ primary=ct.MassFlowController(
161
+ upstream=inlet,
162
+ downstream=reactor,
163
+ mdot=reactor.mass / config.residence_time,
164
+ ),
165
+ )
166
+ reactor_net = ct.ReactorNet([reactor])
167
+ reactor_net.advance_to_steady_state(max_steps=100000)
168
+ return reactor.phase.mole_fraction_dict()
169
+
170
+
171
+ # Helpers
172
+ def generate_cantera_file_from_chemkin(
173
+ config: Config, *, logger: Logger = lambda _: None
174
+ ) -> None:
175
+ """Ensure the Cantera file is available."""
176
+ if config.chemkin_file is None:
177
+ msg = "chemkin_file must be provided if cantera_file does not exist."
178
+ raise ValueError(msg)
179
+
180
+ if not config.chemkin_file.exists():
181
+ msg = f"{config.chemkin_file = } does not exist."
182
+ raise ValueError(msg)
183
+
184
+ logger(f"Converting {config.chemkin_file} to Cantera format")
185
+
186
+ convert.from_chemkin(
187
+ chemkin_file=config.chemkin_file,
188
+ chemkin_thermo_file=config.chemkin_thermo_file,
189
+ cantera_file=config.cantera_file,
190
+ )
191
+
192
+
193
+ def clock_start(logger: Logger, event: str) -> float:
194
+ """Log the start time of an event."""
195
+ start_time = datetime.now(tz=UTC)
196
+ logger(f"{event} at {start_time:%Y-%m-%d %H:%M:%S}")
197
+ return perf_counter()
198
+
199
+
200
+ def clock_end(logger: Logger, event: str, start_counter: float) -> None:
201
+ """Log the start time of a simulation."""
202
+ end_time = datetime.now(tz=UTC)
203
+ elapsed = timedelta(seconds=perf_counter() - start_counter)
204
+ logger(f"{event} at {end_time:%Y-%m-%d %H:%M:%S}")
205
+ logger(f"Elapsed time: {elapsed}")
206
+
207
+
208
+ def is_sequence_of[T](seq: Sequence[object], typ: type[T]) -> TypeGuard[Sequence[T]]:
209
+ """Check the types of the elements in a sequence."""
210
+ return all(isinstance(x, typ) for x in seq)
211
+
212
+
213
+ def hconcat_rename_left(
214
+ df1: pl.DataFrame, df2: pl.DataFrame, suffix: str = "_left"
215
+ ) -> pl.DataFrame:
216
+ """Horizontally concatenate dataframes, renaming the left dataframe columns."""
217
+ clashes = set(df1.columns) & set(df2.columns)
218
+
219
+ df1 = df1.rename({col: f"{col}{suffix}" for col in clashes})
220
+
221
+ return pl.concat([df1, df2], how="horizontal")
@@ -1,211 +0,0 @@
1
- """Reactors."""
2
-
3
- import signal
4
- import textwrap
5
- from collections.abc import Callable, Mapping, Sequence
6
- from copy import replace
7
- from datetime import UTC, datetime, timedelta
8
- from pathlib import Path
9
- from time import perf_counter
10
- from typing import Any
11
-
12
- import cantera as ct
13
- import polars as pl
14
-
15
- from . import convert
16
- from .config import Config
17
-
18
- Logger = Callable[[str], Any]
19
-
20
-
21
- def single(
22
- config: Config, *, logger: Logger | None = None, output_file: Path | None = None
23
- ) -> dict[str, float]:
24
- """Run a single jet-stirred reactor simulation.
25
-
26
- Args:
27
- config: Configuration for the simulation
28
- logger: Optional logger for messages
29
- output_file: Optional file to save simulation results
30
-
31
- Returns:
32
- Steady state mole fractions
33
-
34
- Raises:
35
- TimeoutError: If the simulation exceeds the configured time limit
36
- """
37
- # Set up a timeout handler to prevent simulations from running indefinitely
38
- if config.time_out is not None:
39
- signal.signal(signal.SIGALRM, _timeout_handler)
40
- signal.alarm(config.time_out)
41
-
42
- if logger is not None:
43
- start_time = datetime.now(tz=UTC)
44
- start_counter = perf_counter()
45
- logger(f"Starting simulation at {start_time:%Y-%m-%d %H:%M:%S}")
46
-
47
- if not config.cantera_file.exists():
48
- if config.chemkin_file is None:
49
- msg = "chemkin_file must be provided if cantera_file does not exist."
50
- raise ValueError(msg)
51
-
52
- if not config.chemkin_file.exists():
53
- msg = f"{config.chemkin_file = } does not exist."
54
- raise ValueError(msg)
55
-
56
- if logger is not None:
57
- logger(
58
- f"Converting {config.chemkin_file} to Cantera format "
59
- f"({config.cantera_file})"
60
- )
61
-
62
- convert.from_chemkin(
63
- chemkin_file=config.chemkin_file,
64
- chemkin_thermo_file=config.chemkin_thermo_file,
65
- cantera_file=config.cantera_file,
66
- )
67
-
68
- try:
69
- mole_fracs = _single(config=config)
70
- finally:
71
- signal.alarm(0)
72
-
73
- if logger is not None:
74
- end_time = datetime.now(tz=UTC)
75
- end_counter = perf_counter()
76
- elapsed = timedelta(seconds=end_counter - start_counter)
77
- logger(f"Finished simulation at {end_time:%Y-%m-%d %H:%M:%S}")
78
- logger(f"Elapsed time: {elapsed}")
79
-
80
- if output_file is not None:
81
- pl.from_dicts([mole_fracs]).write_csv(output_file)
82
-
83
- return mole_fracs
84
-
85
-
86
- def multi(
87
- configs: Sequence[Config],
88
- *,
89
- pass_state: bool = True,
90
- output_file: Path | None = None,
91
- logger: Logger | None = None,
92
- ) -> list[dict[str, float]]:
93
- """Run multiple jet-stirred reactor simulations.
94
-
95
- Args:
96
- configs: Configurations for the simulations
97
- pass_state: Whether to pass solved state to the next simulation, if its
98
- initial composition is the same
99
- logger: Optional logger for messages
100
- output_file: Optional file to save simulation results
101
-
102
- Returns:
103
- Steady state mole fractions for each simulation
104
- """
105
- config_count = len(configs)
106
- last_config = last_mole_fracs = None
107
- mole_fracs_lst = []
108
- for num, init_config in enumerate(configs, start=1):
109
- if logger is not None:
110
- yaml_text = init_config.yaml_text(describe=True)
111
- logger(f"Run {num} / {config_count}:")
112
- logger(textwrap.indent(yaml_text.strip(), " "))
113
-
114
- # If requested, re-use solved composition
115
- config = init_config
116
- if (
117
- pass_state
118
- and init_config.is_compatible_with(last_config)
119
- and last_mole_fracs is not None
120
- ):
121
- config = replace(init_config, composition=last_mole_fracs)
122
-
123
- mole_fracs = single(config=config, logger=logger)
124
- mole_fracs_lst.append(mole_fracs)
125
-
126
- last_config = init_config
127
- last_mole_fracs = mole_fracs
128
-
129
- if logger is not None and num < config_count:
130
- logger("")
131
-
132
- if output_file is not None:
133
- pl.from_dicts(mole_fracs_lst).write_csv(output_file)
134
-
135
- return mole_fracs_lst
136
-
137
-
138
- def multi_temperature(
139
- config: Config, temperatures: Sequence[float], *, logger: Logger | None = None
140
- ) -> list[dict[str, float]]:
141
- """Run multiple jet-stirred reactor simulations at different temperatures.
142
-
143
- Args:
144
- config: Base configuration for the simulations
145
- temperatures: List of temperatures (K)
146
- logger: Optional logger for messages
147
-
148
- Returns:
149
- Steady state mole fractions for each simulation
150
- """
151
- configs = [replace(config, temperature=t) for t in temperatures]
152
- return multi(configs, logger=logger)
153
-
154
-
155
- def multi_composition(
156
- config: Config,
157
- compositions: Sequence[Mapping[str, float]],
158
- *,
159
- logger: Logger | None = None,
160
- ) -> list[dict[str, float]]:
161
- """Run multiple jet-stirred reactor simulations at different compositions.
162
-
163
- Args:
164
- config: Base configuration for the simulations
165
- compositions: List of compositions (mole fractions)
166
- logger: Optional logger for messages
167
-
168
- Returns:
169
- Steady state mole fractions for each simulation
170
- """
171
- configs = [replace(config, composition=comp) for comp in compositions]
172
- return multi(configs, logger=logger)
173
-
174
-
175
- def _single(config: Config) -> dict[str, float]:
176
- """Run a single jet-stirred reactor simulation (no timeout handling or logging).
177
-
178
- Args:
179
- config: Configuration for the simulation
180
-
181
- Returns:
182
- Steady state mole fractions
183
- """
184
- phase = ct.Solution(config.cantera_file)
185
-
186
- # Use composition from the previous iteration to speed up convergence
187
- phase.TPX = config.temperature, config.pressure * ct.one_atm, config.composition
188
-
189
- # Set up JSR: inlet -> flow control -> reactor -> pressure control -> exhaust
190
- volume_m3 = config.volume * (1e-2) ** 3
191
- reactor = ct.IdealGasReactor(phase, energy="off", volume=volume_m3, clone=True)
192
- exhaust = ct.Reservoir(phase, clone=True)
193
- inlet = ct.Reservoir(phase, clone=True)
194
- ct.PressureController(
195
- upstream=reactor,
196
- downstream=exhaust,
197
- K=1e-3,
198
- primary=ct.MassFlowController(
199
- upstream=inlet,
200
- downstream=reactor,
201
- mdot=reactor.mass / config.residence_time,
202
- ),
203
- )
204
- reactor_net = ct.ReactorNet([reactor])
205
- reactor_net.advance_to_steady_state(max_steps=100000)
206
- return reactor.phase.mole_fraction_dict()
207
-
208
-
209
- def _timeout_handler(_signum: int, _frame: object) -> None:
210
- msg = "The simulation exceeded the configured time limit."
211
- raise TimeoutError(msg)
File without changes
File without changes
File without changes