edsl 0.1.50__py3-none-any.whl → 0.1.52__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. edsl/__init__.py +45 -34
  2. edsl/__version__.py +1 -1
  3. edsl/base/base_exception.py +2 -2
  4. edsl/buckets/bucket_collection.py +1 -1
  5. edsl/buckets/exceptions.py +32 -0
  6. edsl/buckets/token_bucket_api.py +26 -10
  7. edsl/caching/cache.py +5 -2
  8. edsl/caching/remote_cache_sync.py +5 -5
  9. edsl/caching/sql_dict.py +12 -11
  10. edsl/config/__init__.py +1 -1
  11. edsl/config/config_class.py +4 -2
  12. edsl/conversation/Conversation.py +9 -5
  13. edsl/conversation/car_buying.py +1 -3
  14. edsl/conversation/mug_negotiation.py +2 -6
  15. edsl/coop/__init__.py +11 -8
  16. edsl/coop/coop.py +15 -13
  17. edsl/coop/coop_functions.py +1 -1
  18. edsl/coop/ep_key_handling.py +1 -1
  19. edsl/coop/price_fetcher.py +2 -2
  20. edsl/coop/utils.py +2 -2
  21. edsl/dataset/dataset.py +144 -63
  22. edsl/dataset/dataset_operations_mixin.py +14 -6
  23. edsl/dataset/dataset_tree.py +3 -3
  24. edsl/dataset/display/table_renderers.py +6 -3
  25. edsl/dataset/file_exports.py +4 -4
  26. edsl/dataset/r/ggplot.py +3 -3
  27. edsl/inference_services/available_model_fetcher.py +2 -2
  28. edsl/inference_services/data_structures.py +5 -5
  29. edsl/inference_services/inference_service_abc.py +1 -1
  30. edsl/inference_services/inference_services_collection.py +1 -1
  31. edsl/inference_services/service_availability.py +3 -3
  32. edsl/inference_services/services/azure_ai.py +3 -3
  33. edsl/inference_services/services/google_service.py +1 -1
  34. edsl/inference_services/services/test_service.py +1 -1
  35. edsl/instructions/change_instruction.py +5 -4
  36. edsl/instructions/instruction.py +1 -0
  37. edsl/instructions/instruction_collection.py +5 -4
  38. edsl/instructions/instruction_handler.py +10 -8
  39. edsl/interviews/answering_function.py +20 -21
  40. edsl/interviews/exception_tracking.py +3 -2
  41. edsl/interviews/interview.py +1 -1
  42. edsl/interviews/interview_status_dictionary.py +1 -1
  43. edsl/interviews/interview_task_manager.py +7 -4
  44. edsl/interviews/request_token_estimator.py +3 -2
  45. edsl/interviews/statistics.py +2 -2
  46. edsl/invigilators/invigilators.py +34 -6
  47. edsl/jobs/__init__.py +39 -2
  48. edsl/jobs/async_interview_runner.py +1 -1
  49. edsl/jobs/check_survey_scenario_compatibility.py +5 -5
  50. edsl/jobs/data_structures.py +2 -2
  51. edsl/jobs/html_table_job_logger.py +494 -257
  52. edsl/jobs/jobs.py +2 -2
  53. edsl/jobs/jobs_checks.py +5 -5
  54. edsl/jobs/jobs_component_constructor.py +2 -2
  55. edsl/jobs/jobs_pricing_estimation.py +1 -1
  56. edsl/jobs/jobs_runner_asyncio.py +2 -2
  57. edsl/jobs/jobs_status_enums.py +1 -0
  58. edsl/jobs/remote_inference.py +47 -13
  59. edsl/jobs/results_exceptions_handler.py +2 -2
  60. edsl/language_models/language_model.py +151 -145
  61. edsl/notebooks/__init__.py +24 -1
  62. edsl/notebooks/exceptions.py +82 -0
  63. edsl/notebooks/notebook.py +7 -3
  64. edsl/notebooks/notebook_to_latex.py +1 -1
  65. edsl/prompts/__init__.py +23 -2
  66. edsl/prompts/prompt.py +1 -1
  67. edsl/questions/__init__.py +4 -4
  68. edsl/questions/answer_validator_mixin.py +0 -5
  69. edsl/questions/compose_questions.py +2 -2
  70. edsl/questions/descriptors.py +1 -1
  71. edsl/questions/question_base.py +32 -3
  72. edsl/questions/question_base_prompts_mixin.py +4 -4
  73. edsl/questions/question_budget.py +503 -102
  74. edsl/questions/question_check_box.py +658 -156
  75. edsl/questions/question_dict.py +176 -2
  76. edsl/questions/question_extract.py +401 -61
  77. edsl/questions/question_free_text.py +77 -9
  78. edsl/questions/question_functional.py +118 -9
  79. edsl/questions/{derived/question_likert_five.py → question_likert_five.py} +2 -2
  80. edsl/questions/{derived/question_linear_scale.py → question_linear_scale.py} +3 -4
  81. edsl/questions/question_list.py +246 -26
  82. edsl/questions/question_matrix.py +586 -73
  83. edsl/questions/question_multiple_choice.py +213 -47
  84. edsl/questions/question_numerical.py +360 -29
  85. edsl/questions/question_rank.py +401 -124
  86. edsl/questions/question_registry.py +3 -3
  87. edsl/questions/{derived/question_top_k.py → question_top_k.py} +3 -3
  88. edsl/questions/{derived/question_yes_no.py → question_yes_no.py} +3 -4
  89. edsl/questions/register_questions_meta.py +2 -1
  90. edsl/questions/response_validator_abc.py +6 -2
  91. edsl/questions/response_validator_factory.py +10 -12
  92. edsl/results/report.py +1 -1
  93. edsl/results/result.py +7 -4
  94. edsl/results/results.py +500 -271
  95. edsl/results/results_selector.py +2 -2
  96. edsl/scenarios/construct_download_link.py +3 -3
  97. edsl/scenarios/scenario.py +1 -2
  98. edsl/scenarios/scenario_list.py +41 -23
  99. edsl/surveys/survey_css.py +3 -3
  100. edsl/surveys/survey_simulator.py +2 -1
  101. edsl/tasks/__init__.py +22 -2
  102. edsl/tasks/exceptions.py +72 -0
  103. edsl/tasks/task_history.py +48 -11
  104. edsl/templates/error_reporting/base.html +37 -4
  105. edsl/templates/error_reporting/exceptions_table.html +105 -33
  106. edsl/templates/error_reporting/interview_details.html +130 -126
  107. edsl/templates/error_reporting/overview.html +21 -25
  108. edsl/templates/error_reporting/report.css +215 -46
  109. edsl/templates/error_reporting/report.js +122 -20
  110. edsl/tokens/__init__.py +27 -1
  111. edsl/tokens/exceptions.py +37 -0
  112. edsl/tokens/interview_token_usage.py +3 -2
  113. edsl/tokens/token_usage.py +4 -3
  114. {edsl-0.1.50.dist-info → edsl-0.1.52.dist-info}/METADATA +1 -1
  115. {edsl-0.1.50.dist-info → edsl-0.1.52.dist-info}/RECORD +118 -116
  116. edsl/questions/derived/__init__.py +0 -0
  117. {edsl-0.1.50.dist-info → edsl-0.1.52.dist-info}/LICENSE +0 -0
  118. {edsl-0.1.50.dist-info → edsl-0.1.52.dist-info}/WHEEL +0 -0
  119. {edsl-0.1.50.dist-info → edsl-0.1.52.dist-info}/entry_points.txt +0 -0
@@ -1,13 +1,52 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import Any, Optional, Union
4
-
5
- from pydantic import BaseModel, Field
4
+ import re
5
+ from pydantic import BaseModel, model_validator, ValidationError
6
6
 
7
7
  from .question_base import QuestionBase
8
8
  from .descriptors import NumericalOrNoneDescriptor
9
9
  from .decorators import inject_exception
10
10
  from .response_validator_abc import ResponseValidatorABC
11
+ from .exceptions import QuestionAnswerValidationError
12
+
13
+
14
+ class NumericalResponse(BaseModel):
15
+ """
16
+ Pydantic model for validating numerical responses.
17
+
18
+ This model defines the structure and validation rules for responses to
19
+ numerical questions. It ensures that responses contain a valid number
20
+ and that the number falls within any specified range constraints.
21
+
22
+ Attributes:
23
+ answer: The numerical response (int or float)
24
+ comment: Optional comment provided with the answer
25
+ generated_tokens: Optional raw LLM output for token tracking
26
+
27
+ Examples:
28
+ >>> # Valid response with just answer
29
+ >>> response = NumericalResponse(answer=42)
30
+ >>> response.answer
31
+ 42
32
+
33
+ >>> # Valid response with comment
34
+ >>> response = NumericalResponse(answer=3.14, comment="Pi approximation")
35
+ >>> response.answer
36
+ 3.14
37
+ >>> response.comment
38
+ 'Pi approximation'
39
+
40
+ >>> # Invalid non-numeric answer
41
+ >>> try:
42
+ ... NumericalResponse(answer="not a number")
43
+ ... except Exception as e:
44
+ ... print("Validation error occurred")
45
+ Validation error occurred
46
+ """
47
+ answer: Union[int, float]
48
+ comment: Optional[str] = None
49
+ generated_tokens: Optional[Any] = None
11
50
 
12
51
 
13
52
  def create_numeric_response(
@@ -15,23 +54,120 @@ def create_numeric_response(
15
54
  max_value: Optional[float] = None,
16
55
  permissive=False,
17
56
  ):
18
- field_kwargs = {}
19
- if not permissive:
20
- field_kwargs = {}
21
- if min_value is not None:
22
- field_kwargs["ge"] = min_value
23
- if max_value is not None:
24
- field_kwargs["le"] = max_value
25
-
26
- class ConstrainedNumericResponse(BaseModel):
27
- answer: Union[int, float] = Field(**field_kwargs)
28
- comment: Optional[str] = Field(None)
29
- generated_tokens: Optional[Any] = Field(None)
30
-
57
+ """Create a constrained numerical response model with range validation.
58
+
59
+ Examples:
60
+ >>> # Create model with constraints
61
+ >>> ConstrainedModel = create_numeric_response(min_value=0, max_value=100)
62
+ >>> response = ConstrainedModel(answer=42)
63
+ >>> response.answer
64
+ 42
65
+
66
+ >>> # Test min value constraint failure
67
+ >>> try:
68
+ ... ConstrainedModel(answer=-5)
69
+ ... except Exception as e:
70
+ ... "must be greater than or equal to" in str(e)
71
+ True
72
+
73
+ >>> # Test max value constraint failure
74
+ >>> try:
75
+ ... ConstrainedModel(answer=150)
76
+ ... except Exception as e:
77
+ ... "must be less than or equal to" in str(e)
78
+ True
79
+
80
+ >>> # Permissive mode ignores constraints
81
+ >>> PermissiveModel = create_numeric_response(min_value=0, max_value=100, permissive=True)
82
+ >>> response = PermissiveModel(answer=150)
83
+ >>> response.answer
84
+ 150
85
+ """
86
+ if permissive:
87
+ return NumericalResponse
88
+
89
+ class ConstrainedNumericResponse(NumericalResponse):
90
+ """Numerical response model with added range constraints."""
91
+
92
+ @model_validator(mode='after')
93
+ def validate_range_constraints(self):
94
+ """Validate that the number meets range constraints."""
95
+ if min_value is not None and self.answer < min_value:
96
+ validation_error = ValidationError.from_exception_data(
97
+ title='ConstrainedNumericResponse',
98
+ line_errors=[{
99
+ 'type': 'value_error',
100
+ 'loc': ('answer',),
101
+ 'msg': f'Answer must be greater than or equal to {min_value}',
102
+ 'input': self.answer,
103
+ 'ctx': {'error': 'Value too small'}
104
+ }]
105
+ )
106
+ raise QuestionAnswerValidationError(
107
+ message=f"Answer {self.answer} must be greater than or equal to {min_value}",
108
+ data=self.model_dump(),
109
+ model=self.__class__,
110
+ pydantic_error=validation_error
111
+ )
112
+
113
+ if max_value is not None and self.answer > max_value:
114
+ validation_error = ValidationError.from_exception_data(
115
+ title='ConstrainedNumericResponse',
116
+ line_errors=[{
117
+ 'type': 'value_error',
118
+ 'loc': ('answer',),
119
+ 'msg': f'Answer must be less than or equal to {max_value}',
120
+ 'input': self.answer,
121
+ 'ctx': {'error': 'Value too large'}
122
+ }]
123
+ )
124
+ raise QuestionAnswerValidationError(
125
+ message=f"Answer {self.answer} must be less than or equal to {max_value}",
126
+ data=self.model_dump(),
127
+ model=self.__class__,
128
+ pydantic_error=validation_error
129
+ )
130
+ return self
131
+
31
132
  return ConstrainedNumericResponse
32
133
 
33
134
 
34
135
  class NumericalResponseValidator(ResponseValidatorABC):
136
+ """
137
+ Validator for numerical question responses.
138
+
139
+ This class implements the validation and fixing logic for numerical responses.
140
+ It ensures that responses contain valid numbers within specified ranges and
141
+ provides methods to fix common issues in responses.
142
+
143
+ Attributes:
144
+ required_params: List of required parameters for validation.
145
+ valid_examples: Examples of valid responses for testing.
146
+ invalid_examples: Examples of invalid responses for testing.
147
+
148
+ Examples:
149
+ >>> from edsl import QuestionNumerical
150
+ >>> q = QuestionNumerical.example()
151
+ >>> validator = q.response_validator
152
+
153
+ >>> # Fix string to number
154
+ >>> response = {"answer": "42"}
155
+ >>> fixed = validator.fix(response)
156
+ >>> fixed
157
+ {'answer': '42'}
158
+
159
+ >>> # Extract number from text
160
+ >>> response = {"answer": "The answer is 42"}
161
+ >>> fixed = validator.fix(response)
162
+ >>> fixed
163
+ {'answer': '42'}
164
+
165
+ >>> # Preserve comments when fixing
166
+ >>> response = {"answer": "The answer is 42", "comment": "My explanation"}
167
+ >>> fixed = validator.fix(response)
168
+ >>> fixed
169
+ {'answer': '42', 'comment': 'My explanation'}
170
+ """
35
171
  required_params = ["min_value", "max_value", "permissive"]
36
172
 
37
173
  valid_examples = [
@@ -46,30 +182,76 @@ class NumericalResponseValidator(ResponseValidatorABC):
46
182
  ]
47
183
 
48
184
  def fix(self, response, verbose=False):
185
+ """
186
+ Fix common issues in numerical responses.
187
+
188
+ This method attempts to extract valid numbers from text responses,
189
+ handle formatting issues, and ensure the response contains a valid number.
190
+
191
+ Args:
192
+ response: The response dictionary to fix.
193
+ verbose: If True, print information about the fixing process.
194
+
195
+ Returns:
196
+ A fixed version of the response dictionary.
197
+
198
+ Notes:
199
+ - Attempts to extract numbers using regex pattern matching
200
+ - Removes commas from numbers (e.g., "1,000" → "1000")
201
+ - Preserves any comment in the original response
202
+ """
49
203
  response_text = str(response).lower()
50
- import re
51
204
 
52
205
  if verbose:
53
- print(f"Ivalid generated tokens was was: {response_text}")
206
+ print(f"Invalid generated tokens was: {response_text}")
207
+
54
208
  pattern = r"\b\d+(?:\.\d+)?\b"
55
209
  match = re.search(pattern, response_text.replace(",", ""))
56
210
  solution = match.group(0) if match else response.get("answer")
211
+
57
212
  if verbose:
58
213
  print("Proposed solution is: ", solution)
214
+
59
215
  if "comment" in response:
60
216
  return {"answer": solution, "comment": response["comment"]}
61
217
  else:
62
218
  return {"answer": solution}
63
219
 
64
220
  def _check_constraints(self, pydantic_edsl_answer: BaseModel):
221
+ """Method preserved for compatibility, constraints handled in Pydantic model."""
65
222
  pass
66
223
 
67
224
 
68
225
  class QuestionNumerical(QuestionBase):
69
- """This question prompts the agent to answer with a numerical value.
70
-
71
- >>> QuestionNumerical.self_check()
72
-
226
+ """
227
+ A question that prompts the agent to answer with a numerical value.
228
+
229
+ QuestionNumerical is designed for responses that must be numbers, with optional
230
+ range constraints to ensure values fall within acceptable bounds. It's useful for
231
+ age questions, ratings, measurements, and any scenario requiring numerical answers.
232
+
233
+ Attributes:
234
+ question_type (str): Identifier for this question type, set to "numerical".
235
+ min_value: Optional lower bound for acceptable answers.
236
+ max_value: Optional upper bound for acceptable answers.
237
+ _response_model: Initially None, set by create_response_model().
238
+ response_validator_class: Class used to validate and fix responses.
239
+
240
+ Examples:
241
+ >>> # Basic self-check passes
242
+ >>> QuestionNumerical.self_check()
243
+
244
+ >>> # Create age question with range constraints
245
+ >>> q = QuestionNumerical(
246
+ ... question_name="age",
247
+ ... question_text="How old are you in years?",
248
+ ... min_value=0,
249
+ ... max_value=120
250
+ ... )
251
+ >>> q.min_value
252
+ 0
253
+ >>> q.max_value
254
+ 120
73
255
  """
74
256
 
75
257
  question_type = "numerical"
@@ -90,12 +272,36 @@ class QuestionNumerical(QuestionBase):
90
272
  answering_instructions: Optional[str] = None,
91
273
  permissive: bool = False,
92
274
  ):
93
- """Initialize the question.
94
-
95
- :param question_name: The name of the question.
96
- :param question_text: The text of the question.
97
- :param min_value: The minimum value of the answer.
98
- :param max_value: The maximum value of the answer.
275
+ """
276
+ Initialize a new numerical question.
277
+
278
+ Args:
279
+ question_name: Identifier for the question, used in results and templates.
280
+ Must be a valid Python variable name.
281
+ question_text: The actual text of the question to be asked.
282
+ min_value: Optional minimum value for the answer (inclusive).
283
+ max_value: Optional maximum value for the answer (inclusive).
284
+ include_comment: Whether to allow comments with the answer.
285
+ question_presentation: Optional custom presentation template.
286
+ answering_instructions: Optional additional instructions.
287
+ permissive: If True, ignore min/max constraints during validation.
288
+
289
+ Examples:
290
+ >>> q = QuestionNumerical(
291
+ ... question_name="temperature",
292
+ ... question_text="What is the temperature in Celsius?",
293
+ ... min_value=-273.15 # Absolute zero
294
+ ... )
295
+ >>> q.question_name
296
+ 'temperature'
297
+
298
+ >>> # Question with both min and max
299
+ >>> q = QuestionNumerical(
300
+ ... question_name="rating",
301
+ ... question_text="Rate from 1 to 10",
302
+ ... min_value=1,
303
+ ... max_value=10
304
+ ... )
99
305
  """
100
306
  self.question_name = question_name
101
307
  self.question_text = question_text
@@ -108,7 +314,53 @@ class QuestionNumerical(QuestionBase):
108
314
  self.permissive = permissive
109
315
 
110
316
  def create_response_model(self):
317
+ """
318
+ Create a response model with the appropriate constraints.
319
+
320
+ This method creates a Pydantic model customized with the min/max constraints
321
+ specified for this question instance. If permissive=True, constraints are ignored.
322
+
323
+ Returns:
324
+ A Pydantic model class tailored to this question's constraints.
325
+
326
+ Examples:
327
+ >>> q = QuestionNumerical.example()
328
+ >>> model = q.create_response_model()
329
+ >>> model(answer=45).answer
330
+ 45
331
+ """
111
332
  return create_numeric_response(self.min_value, self.max_value, self.permissive)
333
+
334
+ def _simulate_answer(self, human_readable: bool = False) -> dict:
335
+ """
336
+ Generate a simulated valid answer respecting min/max constraints.
337
+
338
+ Overrides the base class method to ensure values are within defined bounds.
339
+
340
+ Args:
341
+ human_readable: Flag for human-readable output (not used for numerical questions)
342
+
343
+ Returns:
344
+ A dictionary with a valid numerical answer within constraints
345
+
346
+ Examples:
347
+ >>> q = QuestionNumerical(question_name="test", question_text="Test", min_value=1, max_value=10)
348
+ >>> answer = q._simulate_answer()
349
+ >>> 1 <= answer["answer"] <= 10
350
+ True
351
+ """
352
+ from random import randint, uniform
353
+
354
+ min_val = self.min_value if self.min_value is not None else 0
355
+ max_val = self.max_value if self.max_value is not None else 100
356
+
357
+ # Generate a value within the constraints
358
+ if isinstance(min_val, int) and isinstance(max_val, int):
359
+ value = randint(int(min_val), int(max_val))
360
+ else:
361
+ value = uniform(float(min_val), float(max_val))
362
+
363
+ return {"answer": value, "comment": None, "generated_tokens": None}
112
364
 
113
365
  ################
114
366
  # Answer methods
@@ -116,6 +368,16 @@ class QuestionNumerical(QuestionBase):
116
368
 
117
369
  @property
118
370
  def question_html_content(self) -> str:
371
+ """
372
+ Generate HTML content for rendering the question in web interfaces.
373
+
374
+ This property generates HTML markup for the question when it needs to be
375
+ displayed in web interfaces or HTML contexts. For a numerical question,
376
+ this is typically an input element with type="number".
377
+
378
+ Returns:
379
+ str: HTML markup for rendering the question.
380
+ """
119
381
  from jinja2 import Template
120
382
 
121
383
  question_html_content = Template(
@@ -133,7 +395,29 @@ class QuestionNumerical(QuestionBase):
133
395
  @classmethod
134
396
  @inject_exception
135
397
  def example(cls, include_comment=False) -> QuestionNumerical:
136
- """Return an example question."""
398
+ """
399
+ Create an example instance of a numerical question.
400
+
401
+ This class method creates a predefined example of a numerical question
402
+ for demonstration, testing, and documentation purposes.
403
+
404
+ Args:
405
+ include_comment: Whether to include a comment field with the answer.
406
+
407
+ Returns:
408
+ QuestionNumerical: An example numerical question.
409
+
410
+ Examples:
411
+ >>> q = QuestionNumerical.example()
412
+ >>> q.question_name
413
+ 'age'
414
+ >>> q.question_text
415
+ 'You are a 45 year old man. How old are you in years?'
416
+ >>> q.min_value
417
+ 0
418
+ >>> q.max_value
419
+ 86.7
420
+ """
137
421
  return cls(
138
422
  question_name="age",
139
423
  question_text="You are a 45 year old man. How old are you in years?",
@@ -143,7 +427,54 @@ class QuestionNumerical(QuestionBase):
143
427
  )
144
428
 
145
429
 
146
- if __name__ == "__main__":
430
+ def main():
431
+ """
432
+ Demonstrate the functionality of the QuestionNumerical class.
433
+
434
+ This function creates an example numerical question and demonstrates its
435
+ key features including validation, serialization, and answer simulation.
436
+ It's primarily intended for testing and development purposes.
437
+
438
+ Note:
439
+ This function will be executed when the module is run directly,
440
+ but not when imported.
441
+ """
442
+ # Create an example question
443
+ q = QuestionNumerical.example()
444
+ print(f"Question text: {q.question_text}")
445
+ print(f"Question name: {q.question_name}")
446
+ print(f"Min value: {q.min_value}")
447
+ print(f"Max value: {q.max_value}")
448
+
449
+ # Validate an answer
450
+ valid_answer = {"answer": 42}
451
+ validated = q._validate_answer(valid_answer)
452
+ print(f"Validated answer: {validated}")
453
+
454
+ # Test constraints - this should be in range
455
+ valid_constrained = {"answer": 75}
456
+ constrained = q._validate_answer(valid_constrained)
457
+ print(f"Valid constrained answer: {constrained}")
458
+
459
+ # Simulate an answer
460
+ simulated = q._simulate_answer()
461
+ print(f"Simulated answer: {simulated}")
462
+
463
+ # Serialization demonstration
464
+ serialized = q.to_dict()
465
+ print(f"Serialized: {serialized}")
466
+ deserialized = QuestionBase.from_dict(serialized)
467
+ print(f"Deserialization successful: {deserialized.question_text == q.question_text}")
468
+
469
+ # Run doctests
147
470
  import doctest
471
+ doctest.testmod(optionflags=doctest.ELLIPSIS)
472
+ print("Doctests completed")
473
+
148
474
 
475
+ if __name__ == "__main__":
476
+ import doctest
149
477
  doctest.testmod(optionflags=doctest.ELLIPSIS)
478
+
479
+ # Uncomment to run demonstration
480
+ # main()