diff --git a/docs/examples/cuboids_demagnetization.md b/docs/examples/cuboids_demagnetization.md
index d14ca0f..cac54d4 100644
--- a/docs/examples/cuboids_demagnetization.md
+++ b/docs/examples/cuboids_demagnetization.md
@@ -36,10 +36,16 @@ import magpylib as magpy
import numpy as np
import pandas as pd
import plotly.express as px
-from loguru import logger
-from magpylib_material_response import get_dataset
+from magpylib_material_response import get_dataset, configure_logging
from magpylib_material_response.demag import apply_demag
from magpylib_material_response.meshing import mesh_all
+from magpylib_material_response.logging_config import get_logger
+
+# Configure logging to see progress messages
+configure_logging()
+
+# Initialize logger for contextualized logging
+logger = get_logger("magpylib_material_response.examples.cuboids_demagnetization")
if magpy.__version__.split(".")[0] != "5":
raise RuntimeError(
@@ -90,15 +96,19 @@ coll_meshed.show()
# apply demagnetization with varying number of cells
colls = [coll]
for target_elems in [1, 2, 8, 16, 32, 64, 128, 256]:
- with logger.contextualize(target_elems=target_elems):
- coll_meshed = mesh_all(
- coll, target_elems=target_elems, per_child_elems=True, min_elems=1
- )
- coll_demag = apply_demag(
- coll_meshed,
- style={"label": f"Coll_demag ({len(coll_meshed.sources_all):3d} cells)"},
- )
- colls.append(coll_demag)
+ logger.info("🔄 Processing demagnetization with {target_elems} target elements", target_elems=target_elems)
+
+ coll_meshed = mesh_all(
+ coll, target_elems=target_elems, per_child_elems=True, min_elems=1
+ )
+
+ coll_demag = apply_demag(
+ coll_meshed,
+ style={"label": f"Coll_demag ({len(coll_meshed.sources_all):3d} cells)"},
+ )
+ colls.append(coll_demag)
+
+ logger.info("✅ Completed demagnetization: {actual_cells} cells created", actual_cells=len(coll_meshed.sources_all))
```
## Compare with FEM analysis
diff --git a/docs/examples/soft_magnets.md b/docs/examples/soft_magnets.md
index 44da19c..619ea1d 100644
--- a/docs/examples/soft_magnets.md
+++ b/docs/examples/soft_magnets.md
@@ -35,9 +35,16 @@ import magpylib as magpy
import numpy as np
import pandas as pd
import plotly.express as px
-from loguru import logger
+from magpylib_material_response import configure_logging
from magpylib_material_response.demag import apply_demag
from magpylib_material_response.meshing import mesh_all
+from magpylib_material_response.logging_config import get_logger
+
+# Configure logging to see progress messages
+configure_logging()
+
+# Initialize logger for contextualized logging
+logger = get_logger("magpylib_material_response.examples.soft_magnets")
magpy.defaults.display.backend = "plotly"
@@ -84,15 +91,19 @@ magpy.show(*coll_meshed)
# apply demagnetization with varying number of cells
colls = [coll]
for target_elems in [1, 2, 8, 16, 32, 64, 128, 256]:
- with logger.contextualize(target_elems=target_elems):
- coll_meshed = mesh_all(
- coll, target_elems=target_elems, per_child_elems=True, min_elems=1
- )
- coll_demag = apply_demag(
- coll_meshed,
- style={"label": f"Coll_demag ({len(coll_meshed.sources_all):3d} cells)"},
- )
- colls.append(coll_demag)
+ logger.info("🔄 Processing demagnetization with {target_elems} target elements", target_elems=target_elems)
+
+ coll_meshed = mesh_all(
+ coll, target_elems=target_elems, per_child_elems=True, min_elems=1
+ )
+
+ coll_demag = apply_demag(
+ coll_meshed,
+ style={"label": f"Coll_demag ({len(coll_meshed.sources_all):3d} cells)"},
+ )
+ colls.append(coll_demag)
+
+ logger.info("✅ Completed demagnetization: {actual_cells} cells created", actual_cells=len(coll_meshed.sources_all))
```
+++ {"user_expressions": []}
diff --git a/docs/index.md b/docs/index.md
index 6b52393..8bea166 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -9,6 +9,7 @@
:glob: true
:maxdepth: 2
+logging
examples/index
```
diff --git a/docs/logging.md b/docs/logging.md
new file mode 100644
index 0000000..35bc529
--- /dev/null
+++ b/docs/logging.md
@@ -0,0 +1,123 @@
+# Logging Configuration
+
+The magpylib-material-response package uses structured logging with
+[Loguru](https://loguru.readthedocs.io/) to provide informative messages about
+computation progress and debugging information.
+
+## Default Behavior
+
+By default, the library **does not output any log messages**. This follows best
+practices for Python libraries to avoid cluttering user output unless explicitly
+requested.
+
+## Enabling Logging
+
+To see log messages from the library, you need to configure logging:
+
+```python
+from magpylib_material_response import configure_logging
+from magpylib_material_response.demag import apply_demag
+
+# Enable logging with default settings (INFO level, colored output to stderr)
+configure_logging()
+
+# Now use the library - you'll see progress messages
+# ... your code here
+```
+
+## Configuration Options
+
+### Log Level
+
+```python
+from magpylib_material_response import configure_logging
+
+# Set to DEBUG for detailed internal operations
+configure_logging(level="DEBUG")
+
+# Set to WARNING to only see important warnings and errors
+configure_logging(level="WARNING")
+
+# Available levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
+```
+
+### Output Destination
+
+```python
+import sys
+from magpylib_material_response import configure_logging
+
+# Output to stdout instead of stderr
+configure_logging(sink=sys.stdout)
+
+# Output to a file
+configure_logging(sink="/path/to/logfile.log")
+```
+
+### Disable Colors and Time
+
+```python
+from magpylib_material_response import configure_logging
+
+# Disable colored output (useful for log files)
+configure_logging(enable_colors=False)
+
+# Disable timestamps
+configure_logging(show_time=False)
+```
+
+## Environment Variables
+
+You can also configure logging using environment variables:
+
+```bash
+# Set log level
+export MAGPYLIB_LOG_LEVEL=DEBUG
+
+# Disable colors
+export MAGPYLIB_LOG_COLORS=false
+
+# Disable timestamps
+export MAGPYLIB_LOG_TIME=false
+```
+
+## Disabling Logging
+
+To completely disable logging output:
+
+```python
+from magpylib_material_response import disable_logging
+
+disable_logging()
+```
+
+## Example Usage
+
+```python
+import magpylib as magpy
+from magpylib_material_response import configure_logging
+from magpylib_material_response.demag import apply_demag
+from magpylib_material_response.meshing import mesh_Cuboid
+
+# Enable logging to see progress
+configure_logging(level="INFO")
+
+# Create a magnet
+magnet = magpy.magnet.Cuboid(dimension=(0.01, 0.01, 0.02), polarization=(0, 0, 1))
+magnet.susceptibility = 0.1
+
+# Mesh the magnet - you'll see meshing progress
+meshed = mesh_Cuboid(magnet, target_elems=1000, verbose=True)
+
+# Apply demagnetization - you'll see computation progress
+apply_demag(meshed, inplace=True)
+```
+
+This will output structured log messages showing the progress of operations,
+timing information, and any warnings or errors.
+
+## See Also
+
+- {doc}`examples/index` - Working examples that demonstrate logging output
+- [Loguru Documentation](https://loguru.readthedocs.io/) - Complete reference
+ for the underlying logging library
diff --git a/src/magpylib_material_response/__init__.py b/src/magpylib_material_response/__init__.py
index 92b9be1..772b50a 100644
--- a/src/magpylib_material_response/__init__.py
+++ b/src/magpylib_material_response/__init__.py
@@ -7,7 +7,8 @@
from __future__ import annotations
from magpylib_material_response._data import get_dataset
+from magpylib_material_response.logging_config import configure_logging, disable_logging
from ._version import version as __version__
-__all__ = ["__version__", "get_dataset"]
+__all__ = ["__version__", "configure_logging", "disable_logging", "get_dataset"]
diff --git a/src/magpylib_material_response/demag.py b/src/magpylib_material_response/demag.py
index 3ca39bd..c0030fa 100644
--- a/src/magpylib_material_response/demag.py
+++ b/src/magpylib_material_response/demag.py
@@ -2,33 +2,18 @@
from __future__ import annotations
-import sys
from collections import Counter
import magpylib as magpy
import numpy as np
-from loguru import logger
from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent, BaseMagnet
from magpylib.magnet import Cuboid
from scipy.spatial.transform import Rotation as R
+from magpylib_material_response.logging_config import get_logger
from magpylib_material_response.utils import timelog
-config = {
- "handlers": [
- {
- "sink": sys.stdout,
- "colorize": True,
- "format": (
- "{time:YYYY-MM-DD at HH:mm:ss}"
- " | {level:^8}"
- " | {function}"
- " | {extra} {level.icon:<2} {message}"
- ),
- },
- ],
-}
-logger.configure(**config)
+logger = get_logger("magpylib_material_response.demag")
def get_susceptibilities(sources, susceptibility=None):
@@ -224,7 +209,9 @@ def demag_tensor(
H_unit_pol = []
for split_ind, src_list_subset in enumerate(src_list_split):
logger.info(
- f"Sources subset {split_ind + 1}/{len(src_list_split)}"
+ "Sources subset {subset_num}/{total_subsets}",
+ subset_num=split_ind + 1,
+ total_subsets=len(src_list_split),
)
if src_list_subset.size > 0:
H_unit_pol.append(
@@ -272,14 +259,16 @@ def filter_distance(
"dimension": np.repeat(dim0, len(src_list), axis=0)[mask],
}
dsf = sum(mask) / len(mask) * 100
- log_msg = (
- "Interaction pairs left after distance factor filtering: "
- f"{dsf:.2f}%"
- )
if dsf == 0:
- logger.opt(colors=True).warning(log_msg)
+ logger.warning(
+ "No interaction pairs left after distance factor filtering",
+ percentage=f"{dsf:.2f}%",
+ )
else:
- logger.opt(colors=True).success(log_msg)
+ logger.info(
+ "Interaction pairs left after distance factor filtering",
+ percentage=f"{dsf:.2f}%",
+ )
out = [mask]
if return_params:
out.append(params)
@@ -304,25 +293,25 @@ def match_pairs(src_list, min_log_time=1):
len_src = len(src_list)
num_of_pairs = len_src**2
with logger.contextualize(task="Match interactions pairs"):
- logger.info("position")
+ logger.debug("Computing position differences")
pos2 = np.tile(pos0, (len_src, 1)) - np.repeat(pos0, len_src, axis=0)
- logger.info("orientation")
+ logger.debug("Computing orientation differences")
rotQ2a = np.tile(rotQ0, (len_src, 1)).reshape((num_of_pairs, -1))
rotQ2b = np.repeat(rotQ0, len_src, axis=0).reshape((num_of_pairs, -1))
- logger.info("dimension")
+ logger.debug("Computing dimension differences")
dim2 = np.tile(dim0, (len_src, 1)) - np.repeat(dim0, len_src, axis=0)
- logger.info("concatenate properties")
+ logger.debug("Concatenating properties for comparison")
prop = (np.concatenate([pos2, rotQ2a, rotQ2b, dim2], axis=1) + 1e-9).round(
8
)
- logger.info("find unique indices")
+ logger.debug("Finding unique interaction pairs")
_, unique_inds, unique_inv_inds = np.unique(
prop, return_index=True, return_inverse=True, axis=0
)
perc = len(unique_inds) / len(unique_inv_inds) * 100
- logger.opt(colors=True).info(
- "Interaction pairs left after pair matching filtering: "
- f"{perc:.2f}%"
+ logger.info(
+ "Interaction pairs left after pair matching filtering",
+ percentage=f"{perc:.2f}%",
)
params = {
@@ -408,10 +397,13 @@ def apply_demag(
if not isinstance(src, BaseMagnet | BaseCurrent | magpy.Sensor)
]
if others_list:
+ counts_others = Counter(s.__class__.__name__ for s in others_list)
+ counts_str = ", ".join(
+ f"{count} {name}" for name, count in counts_others.items()
+ )
msg = (
"Only Magnet and Current sources supported. "
- "Incompatible objects found: "
- f"{Counter(s.__class__.__name__ for s in others_list)}"
+ f"Incompatible objects found: {counts_str}"
)
raise TypeError(msg)
n = len(magnets_list)
@@ -419,9 +411,11 @@ def apply_demag(
inplace_str = f"""{" (inplace)" if inplace else ""}"""
lbl = collection.style.label
coll_str = lbl if lbl else str(collection)
+ # Create a clean message without problematic formatting characters
+ counts_str = ", ".join(f"{count} {name}" for name, count in counts.items())
demag_msg = (
f"Demagnetization{inplace_str} of {coll_str}"
- f" with {n} cells - {counts}"
+ f" with {n} cells ({counts_str})"
)
with timelog(demag_msg, min_log_time=min_log_time):
# set up mr
diff --git a/src/magpylib_material_response/logging_config.py b/src/magpylib_material_response/logging_config.py
new file mode 100644
index 0000000..226239f
--- /dev/null
+++ b/src/magpylib_material_response/logging_config.py
@@ -0,0 +1,138 @@
+"""
+Centralized logging configuration for magpylib-material-response.
+
+This module provides a proper logging setup that:
+- Uses named loggers with package hierarchy
+- Allows user configuration through environment variables
+- Provides sensible defaults for library usage
+- Avoids forcing output to stdout unless explicitly requested
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+
+from loguru import logger
+
+
+def get_logger(name: str | None = None):
+ """
+ Get a named logger for the package.
+
+ Parameters
+ ----------
+ name : str, optional
+ Logger name. If None, uses the package root logger.
+
+ Returns
+ -------
+ loguru.Logger
+ Configured logger instance
+ """
+ if name is None:
+ name = "magpylib_material_response"
+ return logger.bind(module=name)
+
+
+def configure_logging(
+ level: str | None = None,
+ enable_colors: bool | None = None,
+ show_time: bool | None = None,
+ sink=None,
+) -> None:
+ """
+ Configure logging for the package.
+
+ This function should be called by users who want to see logging output
+ from the library. By default, the library doesn't output logs unless
+ explicitly configured.
+
+ Parameters
+ ----------
+ level : str, optional
+ Log level. Defaults to INFO. Can be overridden with MAGPYLIB_LOG_LEVEL env var.
+ enable_colors : bool, optional
+ Enable colored output. Defaults to True for interactive environments.
+ Can be overridden with MAGPYLIB_LOG_COLORS env var.
+ show_time : bool, optional
+ Show timestamps in log messages. Defaults to True.
+ Can be overridden with MAGPYLIB_LOG_TIME env var.
+ sink : optional
+ Log sink. Defaults to sys.stderr. Use sys.stdout for stdout output.
+ """
+ # Remove existing handlers to avoid duplicates
+ logger.remove()
+
+ # Get configuration from environment or use defaults
+ if level is None:
+ level = os.getenv("MAGPYLIB_LOG_LEVEL", "INFO")
+
+ if enable_colors is None:
+ enable_colors = os.getenv("MAGPYLIB_LOG_COLORS", "true").lower() in (
+ "true",
+ "1",
+ "yes",
+ )
+
+ if show_time is None:
+ show_time = os.getenv("MAGPYLIB_LOG_TIME", "true").lower() in (
+ "true",
+ "1",
+ "yes",
+ )
+
+ if sink is None:
+ sink = sys.stdout
+
+ # Custom format function to display structured data cleanly
+ def format_record(record):
+ time_part = (
+ f"{record['time'].strftime('%Y-%m-%d %H:%M:%S')} | "
+ if show_time
+ else ""
+ )
+
+ # Base format
+ base = (
+ f"{time_part}"
+ f"{record['level'].name:^8} | "
+ f"{record['extra'].get('module', 'unknown')} | "
+ f"{record['level'].icon:<2} {record['message']}"
+ )
+
+ # Add extra context (excluding 'module' since it's already shown)
+ extra_items = []
+ for key, value in record["extra"].items():
+ if key != "module":
+ extra_items.append(f"{key}={value}")
+
+ if extra_items:
+ base += " | " + " | ".join(extra_items)
+
+ return base + "\n"
+
+ format_str = format_record
+
+ # Configure the logger
+ logger.add(
+ sink,
+ level=level,
+ format=format_str,
+ colorize=enable_colors,
+ filter=lambda record: (
+ record["extra"].get("module", "").startswith("magpylib_material_response")
+ or record.get("name", "").startswith("magpylib_material_response")
+ ),
+ )
+
+
+def disable_logging() -> None:
+ """Disable all logging output from the package."""
+ logger.remove()
+ logger.add(sink=lambda _: None, level="CRITICAL") # Sink that does nothing
+
+
+# Set up a default minimal configuration that doesn't output anything
+# Users need to call configure_logging() to see logs
+disable_logging()
diff --git a/src/magpylib_material_response/meshing.py b/src/magpylib_material_response/meshing.py
index 62f195f..8872347 100644
--- a/src/magpylib_material_response/meshing.py
+++ b/src/magpylib_material_response/meshing.py
@@ -5,16 +5,18 @@
import magpylib as magpy
import numpy as np
-from loguru import logger
from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent
from scipy.spatial.transform import Rotation as R
+from magpylib_material_response.logging_config import get_logger
from magpylib_material_response.meshing_utils import (
cells_from_dimension,
get_volume,
mask_inside,
)
+logger = get_logger("magpylib_material_response.meshing")
+
def _collection_from_obj_and_cells(obj, cells, **style_kwargs):
susceptibility = getattr(obj, "susceptibility", None)
@@ -66,9 +68,11 @@ def mesh_Cuboid(cuboid, target_elems, verbose=False, **kwargs):
nnn = target_elems
elems = np.prod(nnn)
if verbose:
- logger.opt(colors=True).info(
- f"Meshing Cuboid with {nnn[0]}x{nnn[1]}x{nnn[2]}={elems}"
- f" elements (target={target_elems})"
+ logger.info(
+ "Meshing Cuboid",
+ dimensions=f"{nnn[0]}x{nnn[1]}x{nnn[2]}",
+ elements=elems,
+ target=target_elems,
)
# secure input type
@@ -143,9 +147,11 @@ def mesh_Cylinder(cylinder, target_elems, verbose=False, **kwargs):
nphi, nr, nh = target_elems
elems = np.prod([nphi, nr, nh])
if verbose:
- logger.opt(colors=True).info(
- f"Meshing CylinderSegement with {nphi}x{nr}x{nh}={elems}"
- f" elements (target={target_elems})"
+ logger.info(
+ "Meshing CylinderSegment",
+ dimensions=f"{nphi}x{nr}x{nh}",
+ elements=elems,
+ target=target_elems,
)
r = np.linspace(r1, r2, nr + 1)
dh = h / nh
@@ -423,9 +429,13 @@ def mesh_all(
else:
target_elems_by_child = [max(min_elems, target_elems)] * len(supported_objs)
if incompatible_objs:
+ counts_incompatible = Counter(s.__class__.__name__ for s in incompatible_objs)
+ counts_str = ", ".join(
+ f"{count} {name}" for name, count in counts_incompatible.items()
+ )
msg = (
"Incompatible objects found: "
- f"{Counter(s.__class__.__name__ for s in incompatible_objs)}"
+ f"{counts_str}"
f"\nSupported: {[s.__name__ for s in supported]}."
)
raise TypeError(msg)
diff --git a/src/magpylib_material_response/polyline.py b/src/magpylib_material_response/polyline.py
index e2ddd82..2bcb532 100644
--- a/src/magpylib_material_response/polyline.py
+++ b/src/magpylib_material_response/polyline.py
@@ -1,7 +1,10 @@
from __future__ import annotations
import numpy as np
-from loguru import logger
+
+from magpylib_material_response.logging_config import get_logger
+
+logger = get_logger("magpylib_material_response.polyline")
def _find_circle_center_and_tangent_points(
@@ -40,7 +43,9 @@ def _find_circle_center_and_tangent_points(
d = r / tan_theta
if d > norm_bc * max_ratio or d > norm_ab * max_ratio:
# rold, dold = r, d
- logger.debug(f"r: {r}, d: {d}, norm_ab: {norm_ab}, norm_bc: {norm_bc}")
+ logger.debug(
+ "Fillet parameters adjusted", r=r, d=d, norm_ab=norm_ab, norm_bc=norm_bc
+ )
d = min(norm_bc * max_ratio, norm_ab * max_ratio)
r = d * tan_theta if theta > 0 else 0
# warnings.warn(f"Radius {rold:.4g} is too big and has been reduced to {r:.4g}")
diff --git a/src/magpylib_material_response/utils.py b/src/magpylib_material_response/utils.py
index 7160e6e..f85f017 100644
--- a/src/magpylib_material_response/utils.py
+++ b/src/magpylib_material_response/utils.py
@@ -6,10 +6,13 @@
from contextlib import contextmanager
import magpylib as magpy
-from loguru import logger
from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet
from scipy.spatial.transform import Rotation
+from magpylib_material_response.logging_config import get_logger
+
+logger = get_logger("magpylib_material_response.utils")
+
class ElapsedTimeThread(threading.Thread):
""" "Stoppable thread that logs the time elapsed"""
@@ -39,7 +42,7 @@ def run(self):
and time.time() - self.thread_start > self.min_log_time
and not self._msg_displayed
):
- logger.opt(colors=True).info(f"Start {self.msg}")
+ logger.info("🔄 Starting: {operation}", operation=self.msg)
self._msg_displayed = True
# include a delay here so the thread doesn't uselessly thrash the CPU
time.sleep(max(0.01, self.min_log_time / 5))
@@ -59,11 +62,13 @@ def timelog(msg, min_log_time=1):
thread_timer.stop()
thread_timer.join()
if end is None:
- logger.opt(colors=True).exception(f"{msg} failed")
+ logger.exception("❌ Failed: {operation}", operation=msg)
if end > min_log_time:
- logger.opt(colors=True).success(
- f"{msg} done 🕑 {round(end, 3)}sec"
+ logger.info(
+ "✅ Completed: {operation} in {duration}s",
+ operation=msg,
+ duration=round(end, 3),
)