Repository: NTNU-IHB/PythonFMU
Pull request #91 (2020-03-14 16:47:59)

Add log categories

markaren: Better to define Enums? 
Also, is it possible to turn off adding trailing commas in your formatter?
fcollonval: > Better to define Enums?

I'ld like to keep the dictionary approach to allow user to modify the default list. For instance the standard specification mentioned the following list:

logEvents, logSingularLinearSystems, logNonlinearSystems, logDynamicStateSelection, logStatusWarning, logStatusDiscard, logStatusError, logStatusFatal, logStatusPending, logAll

_There are the standardized names for “Category” and these names should be used if a tool supports the corresponding log category._

So it could be interesting to let the user add/remove some categories depending on their model.

> Also, is it possible to turn off adding trailing commas in your formatter?

I'll do


Patch:
diff --git a/examples/demo.ipynb b/examples/demo.ipynb
index 85f9c93..80c52f1 100644
--- a/examples/demo.ipynb
+++ b/examples/demo.ipynb
@@ -11,9 +11,7 @@
   },
   {
    "cell_type": "markdown",
-   "execution_count": null,
    "metadata": {},
-   "outputs": [],
    "source": [
     "## Installation\n",
     "\n",
@@ -41,55 +39,51 @@
    "metadata": {},
    "outputs": [
     {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "::::::::::::::\n",
-      "./demoslave.py\n",
-      "::::::::::::::\n",
-      "from pythonfmu import Fmi2Causality, Fmi2Variability, Fmi2Slave, Boolean, Integer, Real, String\n",
-      "\n",
-      "slave_class = \"Resistor\"  # REQUIRED - Name of the class extending Fmi2Slave\n",
-      "\n",
-      "\n",
-      "class Resistor(Fmi2Slave):\n",
-      "\n",
-      "    author = \"John Doe\"\n",
-      "    description = \"A simple description\"\n",
-      "\n",
-      "    def __init__(self, **kwargs):\n",
-      "        super().__init__(**kwargs)\n",
-      "\n",
-      "        self.positive_pin_v = 20.\n",
-      "        self.positive_pin_i = 0.001\n",
-      "        self.negative_pin_v = 10.\n",
-      "        self.negative_pin_i = 0.001\n",
-      "        self.delta_v = 10.\n",
-      "        self.i = 0.001\n",
-      "        self.R = 10000.\n",
-      "        \n",
-      "        self.register_variable(Real(\"R\", causality=Fmi2Causality.parameter))\n",
-      "\n",
-      "        self.register_variable(Real(\"positive_pin_v\", causality=Fmi2Causality.input, variability=Fmi2Variability.tunable))\n",
-      "        self.register_variable(Real(\"positive_pin_i\", causality=Fmi2Causality.output))\n",
-      "        self.register_variable(Real(\"negative_pin_v\", causality=Fmi2Causality.input, variability=Fmi2Variability.tunable))\n",
-      "        self.register_variable(Real(\"negative_pin_i\", causality=Fmi2Causality.output))\n",
-      "\n",
-      "        self.register_variable(Real(\"delta_v\", causality=Fmi2Causality.local))\n",
-      "        self.register_variable(Real(\"i\", causality=Fmi2Causality.local))\n",
-      "\n",
-      "    def do_step(self, current_time, step_size):\n",
-      "        self.delta_v = self.positive_pin_v - self.negative_pin_v\n",
-      "        self.i = i = self.delta_v / self.R\n",
-      "        self.positive_pin_i = i\n",
-      "        self.negative_pin_i = -i\n",
-      "        return True\n"
-     ]
+     "data": {
+      "text/plain": [
+       "\u001b[0;32mfrom\u001b[0m \u001b[0mpythonfmu\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mFmi2Causality\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mFmi2Variability\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mFmi2Slave\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mReal\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m\u001b[0;32mclass\u001b[0m \u001b[0mResistor\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mFmi2Slave\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m    \u001b[0mauthor\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m\"John Doe\"\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m    \u001b[0mdescription\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m\"A simple description\"\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m    \u001b[0;32mdef\u001b[0m \u001b[0m__init__\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m__init__\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpositive_pin_v\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m20.\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpositive_pin_i\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m0.001\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mnegative_pin_v\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m10.\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mnegative_pin_i\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m0.001\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdelta_v\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m10.\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mi\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m0.001\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mR\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m10000.\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mregister_variable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mReal\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"R\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcausality\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mFmi2Causality\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mparameter\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvariability\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mFmi2Variability\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mtunable\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mregister_variable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mReal\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"positive_pin_v\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcausality\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mFmi2Causality\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0minput\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mregister_variable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mReal\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"positive_pin_i\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcausality\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mFmi2Causality\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0moutput\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mregister_variable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mReal\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"negative_pin_v\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcausality\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mFmi2Causality\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0minput\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mregister_variable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mReal\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"negative_pin_i\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcausality\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mFmi2Causality\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0moutput\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mregister_variable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mReal\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"delta_v\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcausality\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mFmi2Causality\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mlocal\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mregister_variable\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mReal\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"i\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcausality\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mFmi2Causality\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mlocal\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m    \u001b[0;32mdef\u001b[0m \u001b[0mdo_step\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcurrent_time\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mstep_size\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdelta_v\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpositive_pin_v\u001b[0m \u001b[0;34m-\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mnegative_pin_v\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mi\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mi\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdelta_v\u001b[0m \u001b[0;34m/\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mR\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpositive_pin_i\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mi\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mnegative_pin_i\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m-\u001b[0m\u001b[0mi\u001b[0m\u001b[0;34m\u001b[0m\n",
+       "\u001b[0;34m\u001b[0m        \u001b[0;32mreturn\u001b[0m \u001b[0;32mTrue\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
     }
    ],
    "source": [
-    "%%bash\n",
-    "more ./demoslave.py"
+    "%pycat demoslave.py"
    ]
   },
   {
diff --git a/pythonfmu/fmi2slave.py b/pythonfmu/fmi2slave.py
index d6b8245..eb8b544 100644
--- a/pythonfmu/fmi2slave.py
+++ b/pythonfmu/fmi2slave.py
@@ -23,7 +23,7 @@ FMI2_MODEL_OPTIONS: List[ModelOptions] = [
     ModelOptions("canBeInstantiatedOnlyOncePerProcess", False, "only-one-per-process"),
     ModelOptions("canGetAndSetFMUstate", False, "handle-state"),
     ModelOptions("canSerializeFMUstate", False, "serialize-state"),
-    ModelOptions("canNotUseMemoryManagementFunctions", True, "use-memory-management"),
+    ModelOptions("canNotUseMemoryManagementFunctions", True, "use-memory-management")
 ]
 
 
@@ -38,6 +38,15 @@ class Fmi2Slave(ABC):
     modelName: ClassVar[Optional[str]] = None
     description: ClassVar[Optional[str]] = None
 
+    # Dictionary of (category, description) entries
+    log_categories: Dict[str, str] = {
+        "logStatusWarning": "Log messages with fmi2Warning status.",
+        "logStatusDiscard": "Log messages with fmi2Discard status.",
+        "logStatusError": "Log messages with fmi2Error status.",
+        "logStatusFatal": "Log messages with fmi2Fatal status.",
+        "logAll": "Log all messages."
+    }
+
     def __init__(self, **kwargs):
         self.vars = OrderedDict()
         self.instance_name = kwargs["instance_name"]
@@ -69,7 +78,7 @@ class Fmi2Slave(ABC):
             guid=f"{self.guid!s}",
             generationTool=f"PythonFMU {VERSION}",
             generationDateAndTime=date_str,
-            variableNamingConvention="structured",
+            variableNamingConvention="structured"
         )
         if self.description is not None:
             attrib["description"] = self.description
@@ -92,6 +101,16 @@ class Fmi2Slave(ABC):
 
         SubElement(root, "CoSimulation", attrib=options)
 
+        if len(self.log_categories) > 0:
+            categories = SubElement(root, "LogCategories")
+            for category, description in self.log_categories.items():
+                categories.append(
+                    Element(
+                        "Category",
+                        attrib={"name": category, "description": description},
+                    )
+                )
+
         variables = SubElement(root, "ModelVariables")
         for v in self.vars.values():
             if ScalarVariable.requires_start(v):
@@ -132,7 +151,11 @@ class Fmi2Slave(ABC):
         self.vars[variable_reference] = var
         # Set the unique value reference
         var.value_reference = variable_reference
-        if var.getter is None or var.setter is None and ScalarVariable.setter_required(var):
+        if (
+            var.getter is None
+            or var.setter is None
+            and ScalarVariable.setter_required(var)
+        ):
             owner = self
             if "." in var.name:
                 split = var.name.split(".")
@@ -270,24 +293,42 @@ class Fmi2Slave(ABC):
     def _fmu_state_from_bytes(state: bytes) -> Dict[str, Any]:
         return json.loads(state.decode("utf-8"))
 
-    def log(self, msg: str, status: Fmi2Status = Fmi2Status.ok, category: str = "", debug: bool = False):
+    def log(
+        self,
+        msg: str,
+        status: Fmi2Status = Fmi2Status.ok,
+        category: Optional[str] = None,
+        debug: bool = False
+    ):
         """Log a message to the FMU logger.
         
         Args:
             msg (str) : Log message
             status (Fmi2Status) : Optional, message status (default ok)
-            category (str) : Optional, message category (default "")
+            category (str or None) : Optional, message category (default derived from status)
             debug (bool) : Optional, is this a debug message (default False)
         """
         if self.logger is not None and self.resources is not None:
             if self.__lib is None and not self.__lib_error:
-                library_path = Path(self.resources).parent / "binaries" / get_platform() / (self.modelName + "." + get_lib_extension())
+                library_path = (
+                    Path(self.resources).parent
+                    / "binaries"
+                    / get_platform()
+                    / (self.modelName + "." + get_lib_extension())
+                )
                 try:
                     self.__lib = cdll.LoadLibrary(str(library_path))
                 except:
                     self.__lib_error = True
-                    print(f"Warning. Unable to setup logging for FMU instance: {self.instance_name}")
+                    print(
+                        f"Warning. Unable to setup logging for FMU instance: {self.instance_name}"
+                    )
             if not self.__lib_error:
+                if category is None:
+                    category = f"logStatus{status.name.capitalize()}"
+                    if category not in self.log_categories:
+                        category = "logAll"
+
                 self.__lib.pylog(
                     c_void_p(self.logger),
                     c_int(int(status)),
@@ -295,3 +336,4 @@ class Fmi2Slave(ABC):
                     c_char_p(msg.encode("utf-8")),
                     c_bool(debug)
                 )
+


Test patch:
diff --git a/pythonfmu/tests/test_fmi2slave.py b/pythonfmu/tests/test_fmi2slave.py
index 71de8e2..16d78e8 100644
--- a/pythonfmu/tests/test_fmi2slave.py
+++ b/pythonfmu/tests/test_fmi2slave.py
@@ -98,3 +98,49 @@ def test_Fmi2Slave_setters(fmi_type, value):
         set_method = getattr(slave, f"set_{fmi_type_name}")
         with pytest.raises(TypeError):
             set_method([0], [value])
+
+
+def test_Fmi2Slave_log_categories():
+    class Slave(Fmi2Slave):
+        def do_step(self, t, dt):
+            return True
+    
+    slave = Slave(instance_name="instance")
+    xml = slave.to_xml()
+
+    categories = xml.find("LogCategories")
+    assert len(categories) == len(Slave.log_categories)
+    for category, description in Slave.log_categories.items():
+        assert categories.find(f"Category[@name='{category}'][@description='{description}']") is not None
+
+@pytest.mark.parametrize("new_categories", [
+    dict(),
+    {
+        "logStatusWarning": "Log messages with fmi2Warning status.",
+        "logStatusError": "Log messages with fmi2Error status.",
+        "logStatusFatal": "Log messages with fmi2Fatal status.",
+        "logAll": "Log all messages."
+    },
+    {
+        "logCustom1": "My first custom log category",
+        "logCustom2": "My second custom log category"
+    }
+])
+def test_Fmi2Slave_customized_log_categories(new_categories):
+    class Slave(Fmi2Slave):
+        log_categories = new_categories
+
+        def do_step(self, t, dt):
+            return True
+    
+    slave = Slave(instance_name="instance")
+    xml = slave.to_xml()
+
+    categories = xml.find("LogCategories")
+
+    if len(new_categories) > 0:
+        assert len(categories) == len(Slave.log_categories)
+        for category, description in Slave.log_categories.items():
+            assert categories.find(f"Category[@name='{category}'][@description='{description}']") is not None
+    else:
+        assert categories is None
diff --git a/pythonfmu/tests/test_logger.py b/pythonfmu/tests/test_logger.py
index 4cf1c9d..ca1cf50 100644
--- a/pythonfmu/tests/test_logger.py
+++ b/pythonfmu/tests/test_logger.py
@@ -4,7 +4,11 @@ from unittest.mock import call, MagicMock
 
 from pythonfmu.builder import FmuBuilder
 from pythonfmu.enums import Fmi2Status
+from pythonfmu.fmi2slave import Fmi2Slave
 
+fmpy = pytest.importorskip(
+    "fmpy", reason="fmpy is not available for testing the produced FMU"
+)
 pytestmark = pytest.mark.skipif(
     not FmuBuilder.has_binary(), reason="No binary available for the current platform."
 )
@@ -13,10 +17,6 @@ pytestmark = pytest.mark.skipif(
 @pytest.mark.integration
 @pytest.mark.parametrize("debug_logging", [True, False])
 def test_logger(tmp_path, debug_logging):
-    fmpy = pytest.importorskip(
-        "fmpy", reason="fmpy is not available for testing the produced FMU"
-    )
-
     name = "PythonSlaveWithDebugLogger" if debug_logging else "PythonSlaveWithLogger"
     category = "category"
     message = "log message"
@@ -57,8 +57,7 @@ class {name}(Fmi2Slave):
     script_file.parent.mkdir(parents=True, exist_ok=True)
     script_file.write_text(slave_code)
 
-    fmu = FmuBuilder.build_FMU(script_file, dest=tmp_path)
-
+    FmuBuilder.build_FMU(script_file, dest=tmp_path)
     fmu = tmp_path / f"{name}.fmu"
     assert fmu.exists()
 
@@ -84,3 +83,96 @@ class {name}(Fmi2Slave):
     
     assert logger.call_count == len(Fmi2Status) * (1 + int(debug_logging))
     logger.assert_has_calls(expected_calls)
+
+
+@pytest.mark.integration
+@pytest.mark.parametrize("debug_logging", [True, False])
+@pytest.mark.parametrize("categories", [(), ("logStatusError", "logStatusFatal")])
+def test_log_categories(tmp_path, debug_logging, categories):
+    name = "PythonSlaveDebugCategories" if debug_logging else "PythonSlaveCategories"
+    message = "log message"
+
+    log_calls = [
+        (
+            f"{status.name.upper()} - {debug} - {message}", 
+            status,
+            debug
+        ) for debug, status in itertools.product([True, False], Fmi2Status)
+    ]
+
+    fmu_calls = "\n".join([
+        '        self.log("{}", Fmi2Status.{}, None, {})'.format(c[0], c[1].name, c[2]) for c in log_calls
+    ])
+
+    slave_code = f"""from pythonfmu.fmi2slave import Fmi2Slave, Fmi2Status, Fmi2Causality, Integer, Real, Boolean, String
+
+
+class {name}(Fmi2Slave):
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+
+        self.realIn = 22.0
+        self.realOut = 0.0
+        self.register_variable(Real("realIn", causality=Fmi2Causality.input))
+        self.register_variable(Real("realOut", causality=Fmi2Causality.output))
+
+
+    def do_step(self, current_time, step_size):
+{fmu_calls}
+        return True
+"""
+
+    script_file = tmp_path / "orig" / f"{name.lower()}.py"
+    script_file.parent.mkdir(parents=True, exist_ok=True)
+    script_file.write_text(slave_code)
+
+    FmuBuilder.build_FMU(script_file, dest=tmp_path)
+    fmu = tmp_path / f"{name}.fmu"
+    assert fmu.exists()
+
+    logger = MagicMock()
+
+    # Load the model
+    callbacks = fmpy.fmi2.fmi2CallbackFunctions()
+    callbacks.logger = fmpy.fmi2.fmi2CallbackLoggerTYPE(logger)
+    callbacks.allocateMemory = fmpy.fmi2.fmi2CallbackAllocateMemoryTYPE(fmpy.fmi2.allocateMemory)
+    callbacks.freeMemory = fmpy.fmi2.fmi2CallbackFreeMemoryTYPE(fmpy.fmi2.freeMemory)
+
+    model_description = fmpy.read_model_description(fmu)
+    unzip_dir = fmpy.extract(fmu)
+
+    model = fmpy.fmi2.FMU2Slave(
+        guid=model_description.guid,
+        unzipDirectory=unzip_dir,
+        modelIdentifier=model_description.coSimulation.modelIdentifier,
+        instanceName='instance1')
+    # Instantiate the model
+    model.instantiate(callbacks=callbacks)
+    model.setDebugLogging(debug_logging, categories)
+    model.setupExperiment()
+    model.enterInitializationMode()
+    model.exitInitializationMode()
+    # Execute the model
+    model.doStep(0., 0.1)
+    # Clean the model
+    model.terminate()
+
+    expected_calls = []
+    for c in filter(lambda c: debug_logging or not c[2], log_calls):
+        category = f"logStatus{c[1].name.capitalize()}"
+        if category not in Fmi2Slave.log_categories:
+            category = "logAll"
+        if len(categories) == 0  or category in categories:
+            expected_calls.append(call(
+                logger.call_args[0][0],  # Don't test the first argument
+                b'instance1',
+                int(c[1]),
+                bytes(category, encoding="utf-8"),
+                bytes(c[0], encoding="utf-8")
+            ))
+    
+    n_calls = len(Fmi2Status) if len(categories) == 0 else len(categories)
+
+    assert logger.call_count == n_calls * (1 + int(debug_logging))
+    logger.assert_has_calls(expected_calls)