Repository: radiasoft/sirepo

Issue:
default log parser
We need a default log parser for better code sharing and maybe a default option

Discussion:
gurhar1133: @e-carlin ping
gurhar1133: @e-carlin ping
e-carlin: The structure looks good to me. Paul can make sure bugs weren't introduced. 
moellep: I'll keep testing, but here is one case I found:
- In cloudmc, open the Aurora sim, on the Visualization tab change the Run Mode value to "eigenvalue" and run the simulation. You should see an error message "No fission sites banked on MPI rank 0", but it is showing "An unknown error occurred, check CloudMC log for details".
moellep: Changing the error pattern matcher to IGNORECASE fixes the cloudmc problem, but uncovers another:
![x](https://github.com/radiasoft/sirepo/assets/6516910/4c6cad46-138c-4341-b256-68e35e40ad5f)

So, possibly an option could be added to LogParser to ignorecase, or that should be the default option. And an option for stop_at_first_error, or similar. Or the LogParser could ignore duplicate error lines.
robnagler: I would like to throw in that I am seeing this off of "main": OPAL failed\nAn unknown error occurred. The problem is that this doesn't give any information to debug on the server side. The logs are empty. I have to go to the run dir. Ideally, if there is a problem that is unknown, perhaps the last part of the log should be written to stderr in job_cmd or something that allows a developer or sysadmin to debug.
moellep: I've added some test logs with a39af57
I think we should add a unit test for this because parsing logs is an important job we can improve.
gurhar1133: can you take another look @moellep ?
gurhar1133: I think I addressed most recent comments @moellep 

moellep: Looks good. @e-carlin can you check if your request was implemented and resolve it?

Patch:
diff --git a/sirepo/template/activait.py b/sirepo/template/activait.py
index ffd50001f..2eca1f009 100644
--- a/sirepo/template/activait.py
+++ b/sirepo/template/activait.py
@@ -23,7 +23,6 @@ import sirepo.numpy
 import sirepo.sim_data
 import sirepo.util
 
-_LOG_FILE = "run.log"
 
 _SEGMENT_ROWS = 3
 
@@ -102,17 +101,22 @@ def background_percent_complete(report, run_dir, is_running):
         max_frame = data.models.neuralNet.epochs
         res.frameCount = int(m.group(1)) + 1
         res.percentComplete = float(res.frameCount) * 100 / max_frame
-    error = _parse_activait_log_file(run_dir)
+    error = _range_error(_parse_activate_log(run_dir))
     if error:
         res.error = error
     return res
 
 
-def _parse_activait_log_file(run_dir):
-    for l in pkio.read_text(run_dir.join(_LOG_FILE)).split("\n"):
-        if re.search("AssertionError: Model training failed due to:", l):
-            return _range_error(l)
-    return ""
+def _parse_activate_log(run_dir, log_filename="run.log"):
+    return template_common.LogParser(
+        run_dir,
+        log_filename=log_filename,
+        error_patterns=(
+            r"AssertionError: (Model training failed due to .*)",
+            r"TypeError: (.*)",
+        ),
+        default_msg="",
+    ).parse_for_errors()
 
 
 def _range_error(error):
diff --git a/sirepo/template/cloudmc.py b/sirepo/template/cloudmc.py
index 7e9cb55a9..e05e8f6ff 100644
--- a/sirepo/template/cloudmc.py
+++ b/sirepo/template/cloudmc.py
@@ -128,7 +128,7 @@ def post_execution_processing(
             for f in ply_files:
                 _SIM_DATA.put_sim_file(sim_id, f, f.basename)
         return None
-    return _parse_run_log(run_dir)
+    return _parse_cloudmc_log(run_dir)
 
 
 def python_source_for_model(data, model, qcall, **kwargs):
@@ -690,21 +690,14 @@ def _is_sbatch_run_mode(data):
     return data.models.openmcAnimation.jobRunMode == "sbatch"
 
 
-def _parse_run_log(run_dir):
-    res = ""
-    p = run_dir.join(template_common.RUN_LOG)
-    if not p.exists():
-        return res
-    with pkio.open_text(p) as f:
-        for line in f:
-            # ERROR: Cannot tally flux for an individual nuclide.
-            m = re.match(r"^\s*Error:\s*(.*)$", line, re.IGNORECASE)
-            if m:
-                res = m.group(1)
-                break
-    if res:
-        return res
-    return "An unknown error occurred, check CloudMC log for details"
+def _parse_cloudmc_log(run_dir, log_filename="run.log"):
+    return template_common.LogParser(
+        run_dir,
+        log_filename=log_filename,
+        default_msg="An unknown error occurred, check CloudMC log for details",
+        # ERROR: Cannot tally flux for an individual nuclide.
+        error_patterns=(re.compile(r"^\s*Error:\s*(.*)$", re.IGNORECASE),),
+    ).parse_for_errors()
 
 
 def _source_filename(data):
diff --git a/sirepo/template/epicsllrf.py b/sirepo/template/epicsllrf.py
index 832fcdd4b..25709a446 100644
--- a/sirepo/template/epicsllrf.py
+++ b/sirepo/template/epicsllrf.py
@@ -150,18 +150,6 @@ def _generate_parameters_file(data):
     )
 
 
-def _parse_epics_log(run_dir):
-    res = ""
-    with pkio.open_text(run_dir.join(template_common.RUN_LOG)) as f:
-        for line in f:
-            m = re.match(
-                r"sirepo.template.epicsllrf.EpicsDisconnectError:\s+(.+)", line
-            )
-            if m:
-                return m.group(1)
-    return res
-
-
 def _read_epics_data(run_dir, computed_values):
     s = run_dir.join(_STATUS_FILE)
     if s.exists():
@@ -182,6 +170,15 @@ def _read_epics_data(run_dir, computed_values):
     return PKDict()
 
 
+def _parse_epics_log(run_dir, log_filename="run.log"):
+    return template_common.LogParser(
+        run_dir,
+        log_filename=log_filename,
+        error_patterns=(r"sirepo.template.epicsllrf.EpicsDisconnectError:\s+(.+)",),
+        default_msg="",
+    ).parse_for_errors()
+
+
 def _set_zcu_signal(server_address, model):
     # 'model': {'amp': 32000, 'duration': 1023, 'start': 0},
     # 'serverAddress': 'localhost'
diff --git a/sirepo/template/madx.py b/sirepo/template/madx.py
index 89f36d340..6855c5dcb 100644
--- a/sirepo/template/madx.py
+++ b/sirepo/template/madx.py
@@ -159,6 +159,15 @@ class LibAdapter(sirepo.lib.LibAdapterBase):
         return PKDict()
 
 
+class _MadxLogParser(template_common.LogParser):
+    def _parse_log_line(self, line):
+        if re.search(r"^\++ (error|warning):", line, re.IGNORECASE):
+            return re.sub(r"^\++ ", "", line) + "\n"
+        elif re.search(r"^\+.*? fatal:", line, re.IGNORECASE):
+            return re.sub(r"^.*? ", "", line) + "\n"
+        return None
+
+
 class MadxOutputFileIterator(lattice.ModelIterator):
     def __init__(self):
         self.result = PKDict(
@@ -357,7 +366,7 @@ def get_data_file(run_dir, model, frame, options):
 def post_execution_processing(success_exit, run_dir, **kwargs):
     if success_exit:
         return None
-    return _parse_madx_log(run_dir)
+    return _MadxLogParser(run_dir, log_filename=MADX_LOG_FILE).parse_for_errors()
 
 
 def prepare_for_client(data, qcall, **kwargs):
@@ -977,22 +986,6 @@ def _output_info(run_dir):
     return res
 
 
-def _parse_madx_log(run_dir):
-    path = run_dir.join(MADX_LOG_FILE)
-    if not path.exists():
-        return ""
-    res = ""
-    with pkio.open_text(str(path)) as f:
-        for line in f:
-            if re.search(r"^\++ (error|warning):", line, re.IGNORECASE):
-                line = re.sub(r"^\++ ", "", line)
-                res += line + "\n"
-            elif re.search(r"^\+.*? fatal:", line, re.IGNORECASE):
-                line = re.sub(r"^.*? ", "", line)
-                res += line + "\n"
-    return res
-
-
 def _parse_match_summary(run_dir, filename):
     path = run_dir.join(filename)
     node_names = ""
diff --git a/sirepo/template/opal.py b/sirepo/template/opal.py
index 7975e15ae..c0bb5066a 100644
--- a/sirepo/template/opal.py
+++ b/sirepo/template/opal.py
@@ -116,6 +116,17 @@ class OpalElementIterator(lattice.ElementIterator):
         return field == "name" or field == self.IS_DISABLED_FIELD
 
 
+class _OpalLogParser(template_common.LogParser):
+    def _parse_log_line(self, line):
+        if re.search(r"^Error.*?>\s*[\w\"]", line):
+            l = re.sub(r"Error.*?>\s*", "", line.rstrip()).rstrip()
+            if re.search(r"1DPROFILE1-DEFAULT", l):
+                return None
+            if l:
+                return l + "\n"
+        return None
+
+
 class OpalOutputFileIterator(lattice.ModelIterator):
     def __init__(self, preserve_output_filenames=False):
         self.result = PKDict(
@@ -513,23 +524,7 @@ def read_frame_count(run_dir):
 
 
 def parse_opal_log(run_dir):
-    res = ""
-    p = run_dir.join((OPAL_OUTPUT_FILE))
-    if not p.exists():
-        return res
-    with pkio.open_text(p) as f:
-        visited = set()
-        for line in f:
-            if re.search(r"^Error.*?>\s*\w", line):
-                line = re.sub(r"Error.*?>\s*", "", line.rstrip()).rstrip()
-                if re.search(r"1DPROFILE1-DEFAULT", line):
-                    continue
-                if line and line not in visited:
-                    res += line + "\n"
-                    visited.add(line)
-    if res:
-        return res
-    return "An unknown error occurred"
+    return _OpalLogParser(run_dir, log_filename=OPAL_OUTPUT_FILE).parse_for_errors()
 
 
 def post_execution_processing(success_exit, is_parallel, run_dir, **kwargs):
diff --git a/sirepo/template/shadow.py b/sirepo/template/shadow.py
index 44269d90f..56b7248a5 100644
--- a/sirepo/template/shadow.py
+++ b/sirepo/template/shadow.py
@@ -938,16 +938,19 @@ def _item_field(item, fields):
     return _fields("oe", item, fields)
 
 
-def _parse_shadow_log(run_dir):
-    if run_dir.join(template_common.RUN_LOG).exists():
-        text = pkio.read_text(run_dir.join(template_common.RUN_LOG))
-        for line in text.split("\n"):
-            if re.search(r"invalid chemical formula", line):
-                return "A mirror contains an invalid reflectivity material"
-            m = re.search("ValueError: (.*)?", line)
-            if m:
-                return m.group(1)
-    return "an unknown error occurred"
+def _parse_shadow_log(run_dir, log_filename="run.log"):
+    if template_common.LogParser(
+        run_dir,
+        log_filename=log_filename,
+        error_patterns=(r".*(invalid chemical formula)",),
+        default_msg="",
+    ).parse_for_errors():
+        return "A mirror contains an invalid reflectivity material"
+    return template_common.LogParser(
+        run_dir,
+        log_filename=log_filename,
+        error_patterns=(r"ValueError: (.*)?",),
+    ).parse_for_errors()
 
 
 def _photon_energy(models):
diff --git a/sirepo/template/silas.py b/sirepo/template/silas.py
index 149895aaa..154c8af8b 100644
--- a/sirepo/template/silas.py
+++ b/sirepo/template/silas.py
@@ -27,6 +27,16 @@ _ABCD_DELTA = 1e-3
 _L_SCALE_EQUATION = "numpy.sqrt(numpy.pi) * numpy.sqrt(2) * pulse.sigx_waist"
 
 
+class _SilasLogParser(template_common.LogParser):
+    def _parse_log_line(self, line):
+        if m := re.search(r"^.*Error:\s+(.*)$", line):
+            err = m.group(1)
+            if re.search("Unable to evaluate function at point", err):
+                return "Point evaulated outside of mesh boundary. Consider increasing Mesh Density or Boundary Tolerance."
+            return err + "\n"
+        return None
+
+
 def background_percent_complete(report, run_dir, is_running):
     data = simulation_db.read_json(run_dir.join(template_common.INPUT_BASE_NAME))
     res = PKDict(
@@ -66,7 +76,7 @@ def get_data_file(run_dir, model, frame, options):
 def post_execution_processing(success_exit, run_dir, **kwargs):
     if success_exit:
         return None
-    return _parse_silas_log(run_dir)
+    return _SilasLogParser(run_dir).parse_for_errors()
 
 
 def python_source_for_model(data, model, qcall, **kwargs):
@@ -577,24 +587,6 @@ class _LaserPulsePlot(PKDict):
         raise AssertionError("Report is unavailable")
 
 
-def _parse_silas_log(run_dir):
-    res = ""
-    path = run_dir.join(template_common.RUN_LOG)
-    if not path.exists():
-        return res
-    with pkio.open_text(str(path)) as f:
-        for line in f:
-            m = re.search(r"^.*Error:\s+(.*)$", line)
-            if m:
-                err = m.group(1)
-                if re.search("Unable to evaluate function at point", err):
-                    return "Point evaulated outside of mesh boundary. Consider increasing Mesh Density or Boundary Tolerance."
-                res += err + "\n"
-    if res:
-        return res
-    return "An unknown error occurred"
-
-
 def _convert_laser_pulse_units(laserPulse):
     laserPulse.tau_0 = laserPulse.tau_0 / 1e12
     laserPulse.tau_fwhm = laserPulse.tau_fwhm / 1e12
diff --git a/sirepo/template/srw.py b/sirepo/template/srw.py
index 207bb61af..3c0c9adf1 100644
--- a/sirepo/template/srw.py
+++ b/sirepo/template/srw.py
@@ -596,7 +596,7 @@ def post_execution_processing(
             f = _SIM_DATA.ML_OUTPUT
             _SIM_DATA.put_sim_file(sim_id, run_dir.join(f), f)
         return None
-    return _parse_srw_log(run_dir)
+    return template_common.LogParser(run_dir).parse_for_errors()
 
 
 def prepare_for_client(data, qcall, **kwargs):
@@ -2167,21 +2167,6 @@ def _machine_learning_percent_complete(run_dir, res):
     return res
 
 
-def _parse_srw_log(run_dir):
-    res = ""
-    p = run_dir.join(template_common.RUN_LOG)
-    if not p.exists():
-        return res
-    with pkio.open_text(p) as f:
-        for line in f:
-            m = re.search(r"Error: (.*)", line)
-            if m:
-                res += m.group(1) + "\n"
-    if res:
-        return res
-    return "An unknown error occurred"
-
-
 def _process_rsopt_elements(els):
     x = [e for e in els if e.enabled and e.enabled != "0"]
     names = []
diff --git a/sirepo/template/template_common.py b/sirepo/template/template_common.py
index 330ac4e4c..ad9360fd1 100644
--- a/sirepo/template/template_common.py
+++ b/sirepo/template/template_common.py
@@ -92,6 +92,40 @@ class JobCmdFile(PKDict):
         )
 
 
+class LogParser(PKDict):
+    def __init__(self, run_dir, **kwargs):
+        super().__init__(run_dir=run_dir, **kwargs)
+        self.pksetdefault(
+            default_msg="An unknown error occurred",
+            error_patterns=(r"Error: (.*)",),
+            log_filename=RUN_LOG,
+        )
+
+    def parse_for_errors(self):
+        p = self.run_dir.join(self.log_filename)
+        if not p.exists():
+            return ""
+        res = ""
+        e = set()
+        with pkio.open_text(p) as f:
+            for line in f:
+                if m := self._parse_log_line(line):
+                    if m not in e:
+                        res += m
+                        e.add(m)
+        if res:
+            return res
+        return self.default_msg
+
+    def _parse_log_line(self, line):
+        res = ""
+        for pattern in self.error_patterns:
+            m = re.search(pattern, line)
+            if m:
+                res += m.group(1) + "\n"
+        return res
+
+
 class ModelUnits:
     """Convert model fields from native to sirepo format, or from sirepo to native format.
 
@@ -165,6 +199,21 @@ class ModelUnits:
         return model
 
 
+class _MPILogParser(LogParser):
+    def parse_for_errors(self):
+        p = self.run_dir.join(self.log_filename)
+        e = None
+        if p.exists():
+            m = re.search(
+                r"^Traceback .*?^\w*Error: (.*?)\n",
+                pkio.read_text(p),
+                re.MULTILINE | re.DOTALL,
+            )
+            if m:
+                e = m.group(1)
+        return e
+
+
 class NamelistParser:
     def parse_text(self, text):
         import f90nml
@@ -579,17 +628,7 @@ def parse_enums(enum_schema):
 
 
 def parse_mpi_log(run_dir):
-    e = None
-    f = run_dir.join(sirepo.const.MPI_LOG)
-    if f.exists():
-        m = re.search(
-            r"^Traceback .*?^\w*Error: (.*?)\n",
-            pkio.read_text(f),
-            re.MULTILINE | re.DOTALL,
-        )
-        if m:
-            e = m.group(1)
-    return e
+    return _MPILogParser(run_dir, log_filename=sirepo.const.MPI_LOG).parse_for_errors()
 
 
 def read_dict_from_h5(file_path, h5_path=None):
diff --git a/sirepo/template/zgoubi.py b/sirepo/template/zgoubi.py
index 43c11e522..5376b490c 100644
--- a/sirepo/template/zgoubi.py
+++ b/sirepo/template/zgoubi.py
@@ -286,6 +286,30 @@ _TWISS_SUMMARY_LABELS = PKDict(
 )
 
 
+class _ZgoubiLogParser(template_common.LogParser):
+    def __init__(self, run_dir, **kwargs):
+        super().__init__(run_dir, **kwargs)
+        self.elements_by_num = PKDict()
+
+    def _parse_log_line(self, line):
+        match = re.search(r"^ (\'\w+\'.*?)\s+(\d+)$", line)
+        if match:
+            self.element_by_num[match.group(2)] = match.group(1)
+            return
+        if re.search("all particles lost", line):
+            return line
+        if re.search("charge found null", line):
+            return line
+        match = re.search(r"Enjob occured at element # (\d+)", line)
+        if match:
+            res = "{}\n".format(line)
+            num = match.group(1)
+            if num in element_by_num:
+                res = "  element # {}: {}\n".format(num, self.element_by_num[num])
+            return res
+        return None
+
+
 def analysis_job_compute_particle_ranges(data, run_dir, **kwargs):
     return template_common.compute_field_range(
         data,
@@ -915,38 +939,21 @@ def _ipasses_for_data(col_names, rows):
     return res
 
 
-def _parse_zgoubi_log(run_dir):
-    path = run_dir.join(_ZGOUBI_LOG_FILE)
-    if not path.exists():
-        return ""
-    res = ""
-    element_by_num = PKDict()
-    text = pkio.read_text(str(path))
-
-    for line in text.split("\n"):
-        match = re.search(r"^ (\'\w+\'.*?)\s+(\d+)$", line)
-        if match:
-            element_by_num[match.group(2)] = match.group(1)
-            continue
-        if re.search("all particles lost", line):
-            res += "{}\n".format(line)
-            continue
-        if re.search("charge found null", line):
-            res += "{}\n".format(line)
-        match = re.search(r"Enjob occured at element # (\d+)", line)
-        if match:
-            res += "{}\n".format(line)
-            num = match.group(1)
-            if num in element_by_num:
-                res += "  element # {}: {}\n".format(num, element_by_num[num])
-    path = run_dir.join(template_common.RUN_LOG)
-    if res == "" and path.exists():
-        text = pkio.read_text(str(path))
-        for line in text.split("\n"):
-            match = re.search(r"Fortran runtime error: (.*)", line)
-            if match:
-                res += "{}\n".format(match.group(1))
-    return res
+def _parse_zgoubi_log(run_dir, log_filename="run.log"):
+    if (
+        res := _ZgoubiLogParser(
+            run_dir,
+            log_filename=_ZGOUBI_LOG_FILE,
+            default_msg="",
+        ).parse_for_errors()
+    ) != "":
+        return res
+    return template_common.LogParser(
+        run_dir,
+        log_filename=log_filename,
+        error_patterns=(r"Fortran runtime error: (.*)",),
+        default_msg="",
+    ).parse_for_errors()
 
 
 def _particle_count(data):


Test patch:
diff --git a/tests/template/logparser_data/activait1.txt b/tests/template/logparser_data/activait1.txt
new file mode 100644
index 000000000..499fefc46
--- /dev/null
+++ b/tests/template/logparser_data/activait1.txt
@@ -0,0 +1,55 @@
+2024-01-26 20:46:34.804657: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcudart.so.11.0'; dlerror: libcudart.so.11.0: cannot open shared object file: No such file or directory; LD_LIBRARY_PATH: /usr/lib64/mpich/lib:/home/vagrant/.local/lib
+2024-01-26 20:46:34.804726: I tensorflow/stream_executor/cuda/cudart_stub.cc:29] Ignore above cudart dlerror if you do not have a GPU set up on your machine.
+2024-01-26 20:46:34.905065: E tensorflow/stream_executor/cuda/cuda_blas.cc:2981] Unable to register cuBLAS factory: Attempting to register factory for plugin cuBLAS when one has already been registered
+Traceback (most recent call last):
+  File "/home/vagrant/.pyenv/versions/py3/bin/sirepo", line 33, in <module>
+    sys.exit(load_entry_point('sirepo', 'console_scripts', 'sirepo')())
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/sirepo_console.py", line 18, in main
+    return pkcli.main("sirepo")
+  File "/home/vagrant/src/radiasoft/pykern/pykern/pkcli/__init__.py", line 165, in main
+    res = argh.dispatch(parser, argv=argv)
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 179, in dispatch
+    for line in lines:
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 290, in _execute_command
+    for line in result:
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 273, in _call
+    result = function(*positional, **keywords)
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/pkcli/activait.py", line 23, in run_background
+    template_common.exec_parameters()
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/template/template_common.py", line 420, in exec_parameters
+    return pkrunpy.run_path_as_module(path or PARAMETERS_PYTHON_FILE)
+  File "/home/vagrant/src/radiasoft/pykern/pykern/pkrunpy.py", line 28, in run_path_as_module
+    exec(code, m.__dict__)
+  File "parameters.py", line 8, in <module>
+    import keras
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/keras/__init__.py", line 20, in <module>
+    from keras import distribute
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/keras/distribute/__init__.py", line 18, in <module>
+    from keras.distribute import sidecar_evaluator
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/keras/distribute/sidecar_evaluator.py", line 17, in <module>
+    import tensorflow.compat.v2 as tf
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/tensorflow/__init__.py", line 37, in <module>
+    from tensorflow.python.tools import module_util as _module_util
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/tensorflow/python/__init__.py", line 37, in <module>
+    from tensorflow.python.eager import context
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/tensorflow/python/eager/context.py", line 29, in <module>
+    from tensorflow.core.framework import function_pb2
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/tensorflow/core/framework/function_pb2.py", line 16, in <module>
+    from tensorflow.core.framework import attr_value_pb2 as tensorflow_dot_core_dot_framework_dot_attr__value__pb2
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/tensorflow/core/framework/attr_value_pb2.py", line 16, in <module>
+    from tensorflow.core.framework import tensor_pb2 as tensorflow_dot_core_dot_framework_dot_tensor__pb2
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/tensorflow/core/framework/tensor_pb2.py", line 16, in <module>
+    from tensorflow.core.framework import resource_handle_pb2 as tensorflow_dot_core_dot_framework_dot_resource__handle__pb2
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/tensorflow/core/framework/resource_handle_pb2.py", line 16, in <module>
+    from tensorflow.core.framework import tensor_shape_pb2 as tensorflow_dot_core_dot_framework_dot_tensor__shape__pb2
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/tensorflow/core/framework/tensor_shape_pb2.py", line 36, in <module>
+    _descriptor.FieldDescriptor(
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/google/protobuf/descriptor.py", line 553, in __new__
+    _message.Message._CheckCalledFromGeneratedFile()
+TypeError: Descriptors cannot be created directly.
+If this call came from a _pb2.py file, your generated code is out of date and must be regenerated with protoc >= 3.19.0.
+If you cannot immediately regenerate your protos, some other possible workarounds are:
+ 1. Downgrade the protobuf package to 3.20.x or lower.
+ 2. Set PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python (but this will use pure-Python parsing and will be much slower).
+
+More information: https://developers.google.com/protocol-buffers/docs/news/2022-05-06#python-updates
diff --git a/tests/template/logparser_data/cloudmc1.txt b/tests/template/logparser_data/cloudmc1.txt
new file mode 100644
index 000000000..f8dab8014
--- /dev/null
+++ b/tests/template/logparser_data/cloudmc1.txt
@@ -0,0 +1,13 @@
+ ERROR: No fission sites banked on MPI rank 0
+application called MPI_Abort(MPI_COMM_WORLD, -1) - process 0
+[unset]: write_line error; fd=-1 buf=:cmd=abort exitcode=-1
+:
+system msg for write_line failure : Bad file descriptor
+Traceback (most recent call last):
+  File "/home/vagrant/src/radiasoft/sirepo/run/user/deXWKOcN/cloudmc/W1IkrT8i/openmcAnimation/parameters.py", line 85, in <module>
+    openmc.run(threads=6)
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/openmc/executor.py", line 314, in run
+    _run(args, output, cwd)
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/openmc/executor.py", line 125, in _run
+    raise RuntimeError(error_msg)
+RuntimeError: No fission sites banked on MPI rank 0 application called MPI_Abort(MPI_COMM_WORLD, -1) - process 0 [unset]: write_line error; fd=-1 buf=:cmd=abort exitcode=-1 : system msg for write_line failure : Bad file descriptor
diff --git a/tests/template/logparser_data/epicsllrf1.txt b/tests/template/logparser_data/epicsllrf1.txt
new file mode 100644
index 000000000..b0d5a5d4b
--- /dev/null
+++ b/tests/template/logparser_data/epicsllrf1.txt
@@ -0,0 +1,16 @@
+Traceback (most recent call last):
+  File "/home/vagrant/.pyenv/versions/py3/bin/sirepo", line 33, in <module>
+    sys.exit(load_entry_point('sirepo', 'console_scripts', 'sirepo')())
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/sirepo_console.py", line 18, in main
+    return pkcli.main("sirepo")
+  File "/home/vagrant/src/radiasoft/pykern/pykern/pkcli/__init__.py", line 165, in main
+    res = argh.dispatch(parser, argv=argv)
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 179, in dispatch
+    for line in lines:
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 290, in _execute_command
+    for line in result:
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 273, in _call
+    result = function(*positional, **keywords)
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/pkcli/epicsllrf.py", line 38, in run_background
+    raise epicsllrf.EpicsDisconnectError("a test EpicsDisconnectError")
+sirepo.template.epicsllrf.EpicsDisconnectError: a test EpicsDisconnectError
\ No newline at end of file
diff --git a/tests/template/logparser_data/madx1.txt b/tests/template/logparser_data/madx1.txt
new file mode 100644
index 000000000..0c1dee237
--- /dev/null
+++ b/tests/template/logparser_data/madx1.txt
@@ -0,0 +1,2 @@
+++ Error: Test error
+Error: This line does not start with a plus
diff --git a/tests/template/logparser_data/mpi1.txt b/tests/template/logparser_data/mpi1.txt
new file mode 100644
index 000000000..79fa20d83
--- /dev/null
+++ b/tests/template/logparser_data/mpi1.txt
@@ -0,0 +1,28 @@
+Traceback (most recent call last):
+  File "/home/vagrant/.pyenv/versions/py3/bin/sirepo", line 33, in <module>
+    sys.exit(load_entry_point('sirepo', 'console_scripts', 'sirepo')())
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/sirepo_console.py", line 18, in main
+    return pkcli.main("sirepo")
+  File "/home/vagrant/src/radiasoft/pykern/pykern/pkcli/__init__.py", line 165, in main
+    res = argh.dispatch(parser, argv=argv)
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 179, in dispatch
+    for line in lines:
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 290, in _execute_command
+    for line in result:
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 273, in _call
+    result = function(*positional, **keywords)
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/pkcli/srw.py", line 111, in run
+    r = template_common.exec_parameters()
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/template/template_common.py", line 420, in exec_parameters
+    return pkrunpy.run_path_as_module(path or PARAMETERS_PYTHON_FILE)
+  File "/home/vagrant/src/radiasoft/pykern/pykern/pkrunpy.py", line 28, in run_path_as_module
+    exec(code, m.__dict__)
+  File "parameters.py", line 219, in <module>
+    main()
+  File "parameters.py", line 217, in main
+    srwpy.srwl_bl.SRWLBeamline(_name=v.name).calc_all(v, op)
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/srwpy/srwl_bl.py", line 3225, in calc_all
+    _v.w_res, _v.si_res, mesh_si = self.calc_sr_se( #OC16102017
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/srwpy/srwl_bl.py", line 1111, in calc_sr_se
+    srwl.CalcElecFieldSR(wfr, 0, magToUse, arPrecPar) #calculate SR
+RuntimeError: Message from the error.
diff --git a/tests/template/logparser_data/opal1.txt b/tests/template/logparser_data/opal1.txt
new file mode 100644
index 000000000..04d709108
--- /dev/null
+++ b/tests/template/logparser_data/opal1.txt
@@ -0,0 +1,27 @@
+OPAL{0}>                ____  _____       ___
+OPAL{0}>               / __ \|  __ \ /\   | |
+OPAL{0}>              | |  | | |__) /  \  | |
+OPAL{0}>              | |  | |  ___/ /\ \ | |
+OPAL{0}>              | |__| | |  / ____ \| |____
+OPAL{0}>               \____/|_| /_/    \_\______|
+OPAL{0}>
+OPAL{0}> This is OPAL (Object Oriented Parallel Accelerator Library) Version 2022.1.0
+OPAL{0}>                              git rev. unknown
+OPAL{0}>
+OPAL{0}>
+OPAL{0}>                      (c) PSI, http://amas.web.psi.ch
+OPAL{0}>
+OPAL{0}>
+OPAL{0}> The optimiser (former opt-Pilot) is integrated
+OPAL{0}>
+OPAL{0}> Please send cookies, goodies or other motivations (wine and beer ... )
+OPAL{0}> to the OPAL developers opal@lists.psi.ch
+OPAL{0}>
+OPAL{0}> Time: 21:38:11 date: 26/01/2024
+OPAL{0}>
+OPAL{0}> * Reading input stream 'opal.in'
+Error{5}>
+Error{5}> *** User error detected by function "Beam::execute()"
+Error{5}>     "NPART" must be set.
+Error{5}>     "NPART" must be set.
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 5
diff --git a/tests/template/logparser_data/opal2.txt b/tests/template/logparser_data/opal2.txt
new file mode 100644
index 000000000..7125135b7
--- /dev/null
+++ b/tests/template/logparser_data/opal2.txt
@@ -0,0 +1,52 @@
+OPAL{0}>                ____  _____       ___ 
+OPAL{0}>               / __ \|  __ \ /\   | | 
+OPAL{0}>              | |  | | |__) /  \  | |
+OPAL{0}>              | |  | |  ___/ /\ \ | |
+OPAL{0}>              | |__| | |  / ____ \| |____
+OPAL{0}>               \____/|_| /_/    \_\______|
+OPAL{0}> 
+OPAL{0}> This is OPAL (Object Oriented Parallel Accelerator Library) Version 2022.1.0
+OPAL{0}>                              git rev. unknown
+OPAL{0}> 
+OPAL{0}> 
+OPAL{0}>                      (c) PSI, http://amas.web.psi.ch
+OPAL{0}> 
+OPAL{0}> 
+OPAL{0}> The optimiser (former opt-Pilot) is integrated 
+OPAL{0}> 
+OPAL{0}> Please send cookies, goodies or other motivations (wine and beer ... ) 
+OPAL{0}> to the OPAL developers opal@lists.psi.ch
+OPAL{0}> 
+OPAL{0}> Time: 21:42:40 date: 26/01/2024
+OPAL{0}> 
+OPAL{0}> * Reading input stream 'opal.in'
+Error{1}> Error{2}> 
+Error{2}> *** User error detected by function "Beam::execute()"Error{3}> 
+Error{3}> *** User error detected by function "Beam::execute()"
+Error{3}>     "NPART" must be set.Error{4}> 
+Error{4}> *** User error detected by function "Beam::execute()"
+Error{4}>     "NPART" must be set.
+Error{4}> Error{5}> 
+Error{5}> *** User error detected by function "Beam::execute()"
+Error{5}>     "NPART" must be set.
+Error{5}>     "NPART" must be set.
+
+Error{1}> *** User error detected by function "Beam::execute()"
+Error{1}>     "NPART" must be set.
+Error{1}>     "NPART" must be set.
+
+Error{2}>     "NPART" must be set.
+Error{2}>     "NPART" must be set.
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 2
+
+Error{3}>     "NPART" must be set.
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 3
+    "NPART" must be set.
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 4
+Error{0}> 
+Error{0}> *** User error detected by function "Beam::execute()"
+Error{0}>     "NPART" must be set.
+Error{0}>     "NPART" must be set.
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 0
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 1
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 5
diff --git a/tests/template/logparser_data/opal3.txt b/tests/template/logparser_data/opal3.txt
new file mode 100644
index 000000000..2c5b8c80d
--- /dev/null
+++ b/tests/template/logparser_data/opal3.txt
@@ -0,0 +1,109 @@
+OPAL{0}>                ____  _____       ___ 
+OPAL{0}>               / __ \|  __ \ /\   | | 
+OPAL{0}>              | |  | | |__) /  \  | |
+OPAL{0}>              | |  | |  ___/ /\ \ | |
+OPAL{0}>              | |__| | |  / ____ \| |____
+OPAL{0}>               \____/|_| /_/    \_\______|
+OPAL{0}> 
+OPAL{0}> This is OPAL (Object Oriented Parallel Accelerator Library) Version 2022.1.0
+OPAL{0}>                              git rev. unknown
+OPAL{0}> 
+OPAL{0}> 
+OPAL{0}>                      (c) PSI, http://amas.web.psi.ch
+OPAL{0}> 
+OPAL{0}> 
+OPAL{0}> The optimiser (former opt-Pilot) is integrated 
+OPAL{0}> 
+OPAL{0}> Please send cookies, goodies or other motivations (wine and beer ... ) 
+OPAL{0}> to the OPAL developers opal@lists.psi.ch
+OPAL{0}> 
+OPAL{0}> Time: 22:35:26 date: 26/01/2024
+OPAL{0}> 
+OPAL{0}> * Reading input stream 'opal.in'
+Error{1}> S1: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+Error{1}> S1#0: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+Error{2}> Error{4}> S1: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+Error{5}> S1: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+S1: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+Error{2}> S1#0: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+Error{4}> S1#0: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+Error{5}> S1#0: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+OPAL{0}> * ************* W A K E ************************************************************
+OPAL{0}> OpalWake::initWakefunction for element D2
+OPAL{0}> * **********************************************************************************
+OPAL{0}> * ************* F I L T E R ************************************************************
+OPAL{0}> OpalFilter::initOpalFilterfunction 
+OPAL{0}> Error{3}> * **********************************************************************************
+S1: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+OPAL{0}> * ************* W A K E ************************************************************
+Error{3}> S1#0: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+OPAL{0}> OpalWake::initWakefunction for element D2#0
+OPAL{0}> * **********************************************************************************
+Error{0}> S1: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+OPAL{0}> * ************* W A K E ************************************************************
+OPAL{0}> OpalWake::initWakefunction for element S1
+OPAL{0}> * **********************************************************************************
+Error{0}> S1#0: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+OPAL{0}> * ************* W A K E ************************************************************
+OPAL{0}> OpalWake::initWakefunction for element S1#0
+OPAL{0}> * **********************************************************************************
+Error{0}> 
+Error{0}> *** User error detected by function "Distribution::checkFileMomentum"
+Error{0}Error{1}> 
+Error{1}> *** User error detected by function "Distribution::checkFileMomentum"
+Error{1}>     The z-momentum of the particle distribution
+Error{1}>     1958.907713
+Error{2}> 
+Error{2}> *** User error detected by function "Distribution::checkFileMomentum"
+Error{2}>     The z-momentum of the particle distribution
+Error{2}>     1958.907713
+Error{2}>     is different from the momentum given in the "BEAM" commandError{3}> 
+Error{3}> *** User error detected by function "Distribution::checkFileMomentum"
+Error{3}>     The z-momentum of the particle distribution
+Error{3}> Error{4}> 
+Error{4}> *** User error detected by function "Distribution::checkFileMomentum"
+Error{4}>     The z-momentum of the particle distribution
+Error{4}>     1958.907713
+Error{4}>     is different from the momentum given in the "BEAM" command
+Error{4}>     0.000000.
+Error{4Error{5}> 
+Error{5}> *** User error detected by function "Distribution::checkFileMomentum"
+Error{5}>     The z-momentum of the particle distribution
+Error{5}>     1958.907713
+Error{5}>     is different from the momentum given in the "BEAM" command
+Error{5}>     0.000000.
+Error{5}>     When using a "FROMFILE" type distribution
+Error{5}> >     The z-momentum of the particle distribution
+Error{0}>     1958.907713
+Error{0}>     is different from the momentum given in the "BEAM" command
+Error{0}>     0.000000.
+Error{0}>     When using a "FROMFILE" type distribution
+Error{0}>     the momentum in the "BEAM" command should be
+Error{0}>     the same as the momentum of the particles in the file.
+Error{1}>     is different from the momentum given in the "BEAM" command
+Error{1}>     0.000000.
+Error{1}>     When using a "FROMFILE" type distribution
+Error{1}>     the momentum in the "BEAM" command should be
+Error{1}>     the same as the momentum of the particles in the file.
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 1
+
+Error{2}>     0.000000.
+Error{2}>     When using a "FROMFILE" type distribution
+Error{2}>     the momentum in the "BEAM" command should be
+Error{2}>     the same as the momentum of the particles in the file.
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 2
+    1958.907713
+Error{3}>     is different from the momentum given in the "BEAM" command
+Error{3}>     0.000000.
+Error{3}>     When using a "FROMFILE" type distribution
+Error{3}> }>     When using a "FROMFILE" type distribution
+Error{4}>     the momentum in the "BEAM" command should be
+Error{4}>     the same as the momentum of the particles in the file.
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 4
+    the momentum in the "BEAM" command should be
+Error{5}>     the same as the momentum of the particles in the file.
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 5
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 0
+    the momentum in the "BEAM" command should be
+Error{3}>     the same as the momentum of the particles in the file.
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 3
diff --git a/tests/template/logparser_data/opal4.txt b/tests/template/logparser_data/opal4.txt
new file mode 100644
index 000000000..4927be949
--- /dev/null
+++ b/tests/template/logparser_data/opal4.txt
@@ -0,0 +1,55 @@
+OPAL{0}>                ____  _____       ___ 
+OPAL{0}>               / __ \|  __ \ /\   | | 
+OPAL{0}>              | |  | | |__) /  \  | |
+OPAL{0}>              | |  | |  ___/ /\ \ | |
+OPAL{0}>              | |__| | |  / ____ \| |____
+OPAL{0}>               \____/|_| /_/    \_\______|
+OPAL{0}> 
+OPAL{0}> This is OPAL (Object Oriented Parallel Accelerator Library) Version 2022.1.0
+OPAL{0}>                              git rev. unknown
+OPAL{0}> 
+OPAL{0}> 
+OPAL{0}>                      (c) PSI, http://amas.web.psi.ch
+OPAL{0}> 
+OPAL{0}> 
+OPAL{0}> The optimiser (former opt-Pilot) is integrated 
+OPAL{0}> 
+OPAL{0}> Please send cookies, goodies or other motivations (wine and beer ... ) 
+OPAL{0}> to the OPAL developers opal@lists.psi.ch
+OPAL{0}> 
+OPAL{0}> Time: 22:40:08 date: 26/01/2024
+OPAL{0}> 
+OPAL{0}> * Reading input stream 'opal.in'
+Error{3}> S1: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+Error{3}> S1#0: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+Error{5}> S1: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+OPAL{0}> Error{5}> S1#0: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+* ************* W A K E ************************************************************
+OPAL{0}> OpalWake::initWakefunction for element D2
+OPAL{0}> * **********************************************************************************
+OPAL{0}> * ************* F I L T E R ************************************************************
+OPAL{0}> OpalFilter::initOpalFilterfunction 
+OPAL{0}> * **********************************************************************************
+Error{4}> S1: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+OPAL{0}> * ************* W A K E ************************************************************
+OPALError{3}> 
+Error{3}> *** User error detected by function "TrackRun::execute"
+Error{0}> OpalWake::initWakefunction for element D2#0
+OPAL{0}> * **********************************************************************************
+{3}>     "DISTRIBUTION" must be set in "RUN" command.
+Error{3}>     "DISTRIBUTION" must be set in "RUN" command.
+Error{4}> S1#0: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 3
+OPAL{0}> * ************* W A K E ************************************************************
+OPAL{0}> OpalWake::initWakefunction for element S1
+OPAL{0}> * **********************************************************************************
+OPAL{0}> * ************* W A K E ************************************************************
+OPAL{0}> OpalWake::initWakefunction for element S1#0
+OPAL{0}> * **********************************************************************************
+Error{0}> S1: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+Error{0}> S1#0: No filename for a field map given. Will assume the default map "1DPROFILE1-DEFAULT".
+Error{5}> 
+Error{5}> *** User error detected by function "TrackRun::execute"
+Error{5}>     "DISTRIBUTION" must be set in "RUN" command.
+Error{5}>     "DISTRIBUTION" must be set in "RUN" command.
+application called MPI_Abort(MPI_COMM_WORLD, -100) - process 5
diff --git a/tests/template/logparser_data/shadow1.txt b/tests/template/logparser_data/shadow1.txt
new file mode 100644
index 000000000..8fd334a92
--- /dev/null
+++ b/tests/template/logparser_data/shadow1.txt
@@ -0,0 +1,291 @@
+ File with slit boundaries read succesfully.
+ File SRSPEC not found: Creating it!
+ File written to disk: SRDISTR
+ File written to disk: SRSPEC
+ File written to disk: SRANG
+       50000    rays have been rejected so far.
+         587                   accepted.
+      100000    rays have been rejected so far.
+        1173                   accepted.
+      150000    rays have been rejected so far.
+        1723                   accepted.
+      200000    rays have been rejected so far.
+        2264                   accepted.
+ Generated         2500  rays out of        50000
+      250000    rays have been rejected so far.
+        2845                   accepted.
+      300000    rays have been rejected so far.
+        3407                   accepted.
+      350000    rays have been rejected so far.
+        3952                   accepted.
+      400000    rays have been rejected so far.
+        4548                   accepted.
+                   5000
+      450000    rays have been rejected so far.
+        5121                   accepted.
+      500000    rays have been rejected so far.
+        5741                   accepted.
+      550000    rays have been rejected so far.
+        6252                   accepted.
+      600000    rays have been rejected so far.
+        6798                   accepted.
+      650000    rays have been rejected so far.
+        7405                   accepted.
+                   7500
+      700000    rays have been rejected so far.
+        8030                   accepted.
+      750000    rays have been rejected so far.
+        8586                   accepted.
+      800000    rays have been rejected so far.
+        9149                   accepted.
+      850000    rays have been rejected so far.
+        9669                   accepted.
+                  10000
+      900000    rays have been rejected so far.
+       10222                   accepted.
+      950000    rays have been rejected so far.
+       10787                   accepted.
+     1000000    rays have been rejected so far.
+       11367                   accepted.
+     1050000    rays have been rejected so far.
+       11947                   accepted.
+                  12500
+     1100000    rays have been rejected so far.
+       12502                   accepted.
+     1150000    rays have been rejected so far.
+       13086                   accepted.
+     1200000    rays have been rejected so far.
+       13601                   accepted.
+     1250000    rays have been rejected so far.
+       14153                   accepted.
+     1300000    rays have been rejected so far.
+       14708                   accepted.
+                  15000
+     1350000    rays have been rejected so far.
+       15309                   accepted.
+     1400000    rays have been rejected so far.
+       15887                   accepted.
+     1450000    rays have been rejected so far.
+       16445                   accepted.
+     1500000    rays have been rejected so far.
+       17046                   accepted.
+                  17500
+     1550000    rays have been rejected so far.
+       17615                   accepted.
+     1600000    rays have been rejected so far.
+       18163                   accepted.
+     1650000    rays have been rejected so far.
+       18699                   accepted.
+     1700000    rays have been rejected so far.
+       19284                   accepted.
+     1750000    rays have been rejected so far.
+       19814                   accepted.
+                  20000
+     1800000    rays have been rejected so far.
+       20359                   accepted.
+     1850000    rays have been rejected so far.
+       20923                   accepted.
+     1900000    rays have been rejected so far.
+       21501                   accepted.
+     1950000    rays have been rejected so far.
+       22066                   accepted.
+                  22500
+     2000000    rays have been rejected so far.
+       22627                   accepted.
+     2050000    rays have been rejected so far.
+       23208                   accepted.
+     2100000    rays have been rejected so far.
+       23793                   accepted.
+     2150000    rays have been rejected so far.
+       24374                   accepted.
+     2200000    rays have been rejected so far.
+       24977                   accepted.
+                  25000
+     2250000    rays have been rejected so far.
+       25585                   accepted.
+     2300000    rays have been rejected so far.
+       26174                   accepted.
+     2350000    rays have been rejected so far.
+       26723                   accepted.
+     2400000    rays have been rejected so far.
+       27286                   accepted.
+                  27500
+     2450000    rays have been rejected so far.
+       27866                   accepted.
+     2500000    rays have been rejected so far.
+       28470                   accepted.
+     2550000    rays have been rejected so far.
+       29065                   accepted.
+     2600000    rays have been rejected so far.
+       29616                   accepted.
+                  30000
+     2650000    rays have been rejected so far.
+       30223                   accepted.
+     2700000    rays have been rejected so far.
+       30769                   accepted.
+     2750000    rays have been rejected so far.
+       31362                   accepted.
+     2800000    rays have been rejected so far.
+       31926                   accepted.
+     2850000    rays have been rejected so far.
+       32472                   accepted.
+                  32500
+     2900000    rays have been rejected so far.
+       33071                   accepted.
+     2950000    rays have been rejected so far.
+       33634                   accepted.
+     3000000    rays have been rejected so far.
+       34218                   accepted.
+     3050000    rays have been rejected so far.
+       34745                   accepted.
+                  35000
+     3100000    rays have been rejected so far.
+       35329                   accepted.
+     3150000    rays have been rejected so far.
+       35846                   accepted.
+     3200000    rays have been rejected so far.
+       36420                   accepted.
+     3250000    rays have been rejected so far.
+       36937                   accepted.
+                  37500
+     3300000    rays have been rejected so far.
+       37542                   accepted.
+     3350000    rays have been rejected so far.
+       38083                   accepted.
+     3400000    rays have been rejected so far.
+       38637                   accepted.
+     3450000    rays have been rejected so far.
+       39191                   accepted.
+     3500000    rays have been rejected so far.
+       39748                   accepted.
+                  40000
+     3550000    rays have been rejected so far.
+       40362                   accepted.
+     3600000    rays have been rejected so far.
+       40935                   accepted.
+     3650000    rays have been rejected so far.
+       41494                   accepted.
+     3700000    rays have been rejected so far.
+       42031                   accepted.
+                  42500
+     3750000    rays have been rejected so far.
+       42618                   accepted.
+     3800000    rays have been rejected so far.
+       43185                   accepted.
+     3850000    rays have been rejected so far.
+       43779                   accepted.
+     3900000    rays have been rejected so far.
+       44332                   accepted.
+     3950000    rays have been rejected so far.
+       44895                   accepted.
+                  45000
+     4000000    rays have been rejected so far.
+       45453                   accepted.
+     4050000    rays have been rejected so far.
+       46032                   accepted.
+     4100000    rays have been rejected so far.
+       46596                   accepted.
+     4150000    rays have been rejected so far.
+       47170                   accepted.
+                  47500
+     4200000    rays have been rejected so far.
+       47720                   accepted.
+     4250000    rays have been rejected so far.
+       48313                   accepted.
+Traceback (most recent call last):
+  File "/home/vagrant/.pyenv/versions/py3/bin/sirepo", line 33, in <module>
+    sys.exit(load_entry_point('sirepo', 'console_scripts', 'sirepo')())
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/sirepo_console.py", line 18, in main
+    return pkcli.main("sirepo")
+  File "/home/vagrant/src/radiasoft/pykern/pykern/pkcli/__init__.py", line 165, in main
+    res = argh.dispatch(parser, argv=argv)
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 179, in dispatch
+    for line in lines:
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 290, in _execute_command
+    for line in result:
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 273, in _call
+    result = function(*positional, **keywords)
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/pkcli/shadow.py", line 57, in run
+    res = _run_shadow(cfg_dir, data)
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/pkcli/shadow.py", line 121, in _run_shadow
+    beam = template_common.exec_parameters().beam
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/template/template_common.py", line 420, in exec_parameters
+    return pkrunpy.run_path_as_module(path or PARAMETERS_PYTHON_FILE)
+  File "/home/vagrant/src/radiasoft/pykern/pykern/pkrunpy.py", line 28, in run_path_as_module
+    exec(code, m.__dict__)
+  File "parameters.py", line 85, in <module>
+    prerefl(interactive=False, SYMBOL='Rhj', DENSITY=12.4, FILE='mirror-prerefl-6.txt', E_MIN=5000.0, E_MAX=55000.0, E_STEP=100.0)
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/Shadow/ShadowPreprocessorsXraylib.py", line 102, in prerefl
+    tmp = 2e0*(1e0-xraylib.Refractive_Index_Re(iMaterial,energy,density))
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/xraylib.py", line 4984, in Refractive_Index_Re
+    return _xraylib.Refractive_Index_Re(compound, E, density)
+ValueError: Compound is not a valid chemical formula and is not present in the NIST compound database
+     4300000    rays have been rejected so far.
+       48845                   accepted.
+     4350000    rays have been rejected so far.
+       49413                   accepted.
+                  50000
+ ----------------------------------------------------------------
+   source optimization (rejection, variance reduction) used: 
+     4449523    total number of rays have been created.
+       50000                   accepted (stored).
+     4399523                   rejected.
+   88.9904633           created/accepted ratio.
+ ----------------------------------------------------------------
+ Exit from SOURCE
+ Call to RESET
+ Exit from RESET
+ Call to SETSOUR
+ Exit from SETSOUR
+ Call to IMREF
+ Exit from IMREF
+ Call to OPTAXIS
+ Exit from OPTAXIS
+ Call to MSETUP
+ Exit from MSETUP
+ Call to RESTART
+ Exit from RESTART
+ Call to MIRROR
+ MIRROR1: Warning: Empty element (no mirr.xx file)
+ Call to SCREEN
+ Exit from SCREEN
+ Call to IMAGE
+ Exit from IMAGE
+ Call to DEALLOC
+ Exit from DEALLOC
+ Call to RESET
+ Exit from RESET
+ Call to SETSOUR
+ Exit from SETSOUR
+ Call to IMREF
+ Exit from IMREF
+ Call to OPTAXIS
+ Exit from OPTAXIS
+ Call to MSETUP
+ Exit from MSETUP
+ Call to RESTART
+ Exit from RESTART
+ Call to MIRROR
+ MIRROR1: Warning: Empty element (no mirr.xx file)
+ Call to IMAGE
+ Exit from IMAGE
+ Call to DEALLOC
+ Exit from DEALLOC
+ Call to RESET
+ Exit from RESET
+ Call to SETSOUR
+ Exit from SETSOUR
+ Call to IMREF
+ Exit from IMREF
+ Call to OPTAXIS
+ Exit from OPTAXIS
+ Call to MSETUP
+ Exit from MSETUP
+ Call to RESTART
+ Exit from RESTART
+ Call to MIRROR
+ MIRROR1: Warning: Empty element (no mirr.xx file)
+ Call to IMAGE
+ Exit from IMAGE
+ Call to DEALLOC
+ Exit from DEALLOC
diff --git a/tests/template/logparser_data/silas1.txt b/tests/template/logparser_data/silas1.txt
new file mode 100644
index 000000000..3b11c0f4d
--- /dev/null
+++ b/tests/template/logparser_data/silas1.txt
@@ -0,0 +1,22 @@
+Traceback (most recent call last):
+  File "/home/vagrant/.pyenv/versions/py3/bin/sirepo", line 33, in <module>
+    sys.exit(load_entry_point('sirepo', 'console_scripts', 'sirepo')())
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/sirepo_console.py", line 18, in main
+    return pkcli.main("sirepo")
+  File "/home/vagrant/src/radiasoft/pykern/pykern/pkcli/__init__.py", line 165, in main
+    res = argh.dispatch(parser, argv=argv)
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 179, in dispatch
+    for line in lines:
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 290, in _execute_command
+    for line in result:
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 273, in _call
+    result = function(*positional, **keywords)
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/pkcli/silas.py", line 13, in run_background
+    template_common.exec_parameters()
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/template/template_common.py", line 429, in exec_parameters
+    return pkrunpy.run_path_as_module(path or PARAMETERS_PYTHON_FILE)
+  File "/home/vagrant/src/radiasoft/pykern/pykern/pkrunpy.py", line 28, in run_path_as_module
+    exec(code, m.__dict__)
+  File "parameters.py", line 13, in <module>
+    assert 0, "Unable to evaluate function at point"
+AssertionError: Unable to evaluate function at point
\ No newline at end of file
diff --git a/tests/template/logparser_data/srw1.txt b/tests/template/logparser_data/srw1.txt
new file mode 100644
index 000000000..0bddae17b
--- /dev/null
+++ b/tests/template/logparser_data/srw1.txt
@@ -0,0 +1,7 @@
+Before setting up multipole
+Single-electron SR calculation ... Warning: Computation of terminating terms of radiation integrals WAS NOT PERFORMED for some values of input parameters  because asymptotic expansion validity criterion was not satisfied. This may influence the accuracy of the computation.  One can try to fix the problem by modifying Magnetic Field definition range and/or Integration limits.
+completed (lasted 10.51 s)
+Extracting intensity and saving it to a file ... completed (lasted 0.079 s)
+Saving initial wavefront data to a file ... completed (lasted 0.025 s)
+Propagation ... completed (lasted 1.007 s)
+Saving propagated wavefront data to a file ... completed (lasted 0.018 s)
diff --git a/tests/template/logparser_data/srw2.txt b/tests/template/logparser_data/srw2.txt
new file mode 100644
index 000000000..8a937d62b
--- /dev/null
+++ b/tests/template/logparser_data/srw2.txt
@@ -0,0 +1,29 @@
+Before setting up multipole
+Single-electron SR calculation ... Traceback (most recent call last):
+  File "/home/vagrant/.pyenv/versions/py3/bin/sirepo", line 33, in <module>
+    sys.exit(load_entry_point('sirepo', 'console_scripts', 'sirepo')())
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/sirepo_console.py", line 18, in main
+    return pkcli.main("sirepo")
+  File "/home/vagrant/src/radiasoft/pykern/pykern/pkcli/__init__.py", line 165, in main
+    res = argh.dispatch(parser, argv=argv)
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 179, in dispatch
+    for line in lines:
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 290, in _execute_command
+    for line in result:
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/argh/dispatching.py", line 273, in _call
+    result = function(*positional, **keywords)
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/pkcli/srw.py", line 111, in run
+    r = template_common.exec_parameters()
+  File "/home/vagrant/src/radiasoft/sirepo/sirepo/template/template_common.py", line 420, in exec_parameters
+    return pkrunpy.run_path_as_module(path or PARAMETERS_PYTHON_FILE)
+  File "/home/vagrant/src/radiasoft/pykern/pykern/pkrunpy.py", line 28, in run_path_as_module
+    exec(code, m.__dict__)
+  File "parameters.py", line 219, in <module>
+    main()
+  File "parameters.py", line 217, in main
+    srwpy.srwl_bl.SRWLBeamline(_name=v.name).calc_all(v, op)
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/srwpy/srwl_bl.py", line 3225, in calc_all
+    _v.w_res, _v.si_res, mesh_si = self.calc_sr_se( #OC16102017
+  File "/home/vagrant/.pyenv/versions/3.9.15/envs/py3/lib/python3.9/site-packages/srwpy/srwl_bl.py", line 1111, in calc_sr_se
+    srwl.CalcElecFieldSR(wfr, 0, magToUse, arPrecPar) #calculate SR
+RuntimeError: SRW can not compute this case. Longitudinal position of the Observation Plane is within the Integration limits.
diff --git a/tests/template/logparser_data/unknown_error_case.txt b/tests/template/logparser_data/unknown_error_case.txt
new file mode 100644
index 000000000..24ecddc53
--- /dev/null
+++ b/tests/template/logparser_data/unknown_error_case.txt
@@ -0,0 +1,1 @@
+Unknown error case
diff --git a/tests/template/logparser_data/zgoubi1.txt b/tests/template/logparser_data/zgoubi1.txt
new file mode 100644
index 000000000..7bf3a50af
--- /dev/null
+++ b/tests/template/logparser_data/zgoubi1.txt
@@ -0,0 +1,1 @@
+Fortran runtime error: example fortran runtime error
diff --git a/tests/template/logparser_test.py b/tests/template/logparser_test.py
new file mode 100644
index 000000000..fe7ff4974
--- /dev/null
+++ b/tests/template/logparser_test.py
@@ -0,0 +1,152 @@
+"""PyTest for `sirepo.template.template_common.LogParser`
+
+:copyright: Copyright (c) 2016 RadiaSoft LLC.  All Rights Reserved.
+:license: http://www.apache.org/licenses/LICENSE-2.0.html
+"""
+from pykern.pkdebug import pkdc, pkdp, pkdlog, pkdexc
+
+
+def test_activait_logparser():
+    from sirepo.template import activait
+
+    _case(
+        "Descriptors cannot be created directly.\n",
+        "activait1.txt",
+        parser_function=activait._parse_activate_log,
+    )
+
+
+def test_epicsllrf_logparser():
+    from sirepo.template import epicsllrf
+
+    _case(
+        "a test EpicsDisconnectError\n",
+        "epicsllrf1.txt",
+        parser_function=epicsllrf._parse_epics_log,
+    )
+    _case(
+        "",
+        "unknown_error_case.txt",
+        parser_function=epicsllrf._parse_epics_log,
+    )
+
+
+def test_cloudmc_logparser():
+    from sirepo.template import cloudmc
+
+    _case(
+        "No fission sites banked on MPI rank 0\n",
+        "cloudmc1.txt",
+        parser_function=cloudmc._parse_cloudmc_log,
+    )
+    _case(
+        "An unknown error occurred, check CloudMC log for details",
+        "unknown_error_case.txt",
+        parser_function=cloudmc._parse_cloudmc_log,
+    )
+
+
+def test_madx_logparser():
+    from sirepo.template import madx
+
+    _case("Error: Test error\n\n", "madx1.txt", parser_object=madx._MadxLogParser)
+
+
+def test_mpi_logparser():
+    from sirepo.template import template_common
+
+    _case(
+        "Message from the error.",
+        "mpi1.txt",
+        parser_object=template_common._MPILogParser,
+    )
+
+
+def test_opal_logparser():
+    from sirepo.template import opal
+
+    _case('"NPART" must be set.\n', "opal1.txt", parser_object=opal._OpalLogParser)
+    _case('"NPART" must be set.\n', "opal2.txt", parser_object=opal._OpalLogParser)
+    _case(
+        """The z-momentum of the particle distribution
+1958.907713
+is different from the momentum given in the "BEAM" command
+0.000000.
+When using a "FROMFILE" type distribution
+>     The z-momentum of the particle distribution
+the momentum in the "BEAM" command should be
+the same as the momentum of the particles in the file.
+}>     When using a "FROMFILE" type distribution\n""",
+        "opal3.txt",
+        parser_object=opal._OpalLogParser,
+    )
+    _case(
+        """OpalWake::initWakefunction for element D2#0
+"DISTRIBUTION" must be set in "RUN" command.\n""",
+        "opal4.txt",
+        parser_object=opal._OpalLogParser,
+    )
+
+
+def test_shadow_logparser():
+    from sirepo.template import shadow
+
+    _case(
+        "Compound is not a valid chemical formula and is not present in the NIST compound database\n",
+        "shadow1.txt",
+        parser_function=shadow._parse_shadow_log,
+    )
+
+
+def test_silas_logparser():
+    from sirepo.template import silas
+
+    _case(
+        "Point evaulated outside of mesh boundary. Consider increasing Mesh Density or Boundary Tolerance.",
+        "silas1.txt",
+        parser_object=silas._SilasLogParser,
+    )
+    _case(
+        "An unknown error occurred",
+        "unknown_error_case.txt",
+        parser_object=silas._SilasLogParser,
+    )
+
+
+def test_srw_logparser():
+    from sirepo.template import template_common
+
+    _case(
+        "An unknown error occurred", "srw1.txt", parser_object=template_common.LogParser
+    )
+    _case(
+        "SRW can not compute this case. Longitudinal position of the Observation Plane is within the Integration limits.\n",
+        "srw2.txt",
+        parser_object=template_common.LogParser,
+    )
+
+
+def test_zgoubi_logparser():
+    from sirepo.template import zgoubi
+
+    _case("", "unknown_error_case.txt", parser_function=zgoubi._parse_zgoubi_log)
+    _case(
+        "example fortran runtime error\n",
+        "zgoubi1.txt",
+        parser_function=zgoubi._parse_zgoubi_log,
+    )
+
+
+def _case(expect, filename, parser_object=None, parser_function=None):
+    from pykern import pkunit
+
+    if parser_function:
+        pkunit.pkeq(expect, parser_function(pkunit.data_dir(), log_filename=filename))
+        return
+    if parser_object:
+        pkunit.pkeq(
+            expect,
+            parser_object(pkunit.data_dir(), log_filename=filename).parse_for_errors(),
+        )
+        return
+    raise AssertionError("No parser provided for _case()")