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), )